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 confluence;
26pub(crate) mod git_browser;
27pub(crate) mod git_webhook;
28pub(crate) mod integrations;
29
30use std::{
31    collections::{HashMap, VecDeque},
32    fmt::Write,
33    fs,
34    net::{IpAddr, SocketAddr},
35    path::{Path, PathBuf},
36    process::Stdio,
37    sync::Arc,
38    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
39};
40
41use anyhow::{Context, Result};
42use askama::Template;
43use axum::{
44    body::Body,
45    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
46    http::{header, HeaderValue, Request, StatusCode},
47    middleware::{self, Next},
48    response::{Html, IntoResponse, Response},
49    routing::{get, post},
50    Json, Router,
51};
52use serde::{Deserialize, Serialize};
53use tokio::sync::Mutex;
54use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
55
56use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
57use sloc_git::ScheduleStore;
58
59#[derive(Clone)]
60struct CspNonce(String);
61
62static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
63
64use sloc_core::{
65    analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
66    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
67};
68use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
69const MAX_CONCURRENT_ANALYSES: usize = 4;
70
71/// Windows-only helpers that force the native file-picker dialog into the
72/// foreground instead of appearing minimised behind other windows.
73///
74/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
75/// foreground thread so that windows created on our thread inherit focus; and
76/// (b) spin a polling watcher that finds the dialog by title and calls
77/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
78#[cfg(all(target_os = "windows", feature = "native-dialog"))]
79#[allow(clippy::upper_case_acronyms)]
80mod win_dialog_focus {
81    use std::mem::size_of;
82
83    type HWND = *mut core::ffi::c_void;
84    type DWORD = u32;
85    type UINT = u32;
86    type BOOL = i32;
87
88    // Mirror of FLASHWINFO from winuser.h — field names kept in PascalCase to
89    // match the Win32 ABI layout exactly; the #[allow] suppresses the Rust
90    // naming lint for this one struct.
91    #[repr(C)]
92    #[allow(non_snake_case)]
93    struct FLASHWINFO {
94        cbSize: UINT,
95        hwnd: HWND,
96        dwFlags: DWORD,
97        uCount: UINT,
98        dwTimeout: DWORD,
99    }
100
101    const FLASHW_ALL: DWORD = 0x3;
102    const FLASHW_TIMERNOFG: DWORD = 0xC;
103
104    #[link(name = "user32")]
105    extern "system" {
106        fn GetForegroundWindow() -> HWND;
107        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
108        fn BringWindowToTop(hWnd: HWND) -> BOOL;
109        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
110        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
111        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
112        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
113    }
114
115    #[link(name = "kernel32")]
116    extern "system" {
117        fn GetCurrentThreadId() -> DWORD;
118    }
119
120    /// Attaches our thread's input to the foreground window's thread so that
121    /// windows created on our thread inherit foreground focus.  Returns the
122    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
123    /// the thread was already the foreground thread.
124    pub fn attach_to_foreground() -> DWORD {
125        unsafe {
126            let fg_hwnd = GetForegroundWindow();
127            if fg_hwnd.is_null() {
128                return 0;
129            }
130            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
131            let my_tid = GetCurrentThreadId();
132            if fg_tid == my_tid {
133                return 0;
134            }
135            AttachThreadInput(my_tid, fg_tid, 1);
136            fg_tid
137        }
138    }
139
140    /// Undoes `attach_to_foreground`.
141    pub fn detach_from_foreground(fg_tid: DWORD) {
142        if fg_tid == 0 {
143            return;
144        }
145        unsafe {
146            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
147        }
148    }
149
150    /// Spawns a short-lived watcher thread that polls for a dialog window
151    /// matching `title` and, once found, forces it to the foreground and
152    /// flashes its taskbar button until the user interacts with it.
153    pub fn flash_dialog_when_ready(title: String) {
154        std::thread::spawn(move || {
155            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
156            for _ in 0..40 {
157                std::thread::sleep(std::time::Duration::from_millis(80));
158                unsafe {
159                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
160                    if !hwnd.is_null() {
161                        SetForegroundWindow(hwnd);
162                        BringWindowToTop(hwnd);
163                        #[allow(non_snake_case)]
164                        FlashWindowEx(&FLASHWINFO {
165                            // size_of returns usize; Win32 struct field is u32 (UINT).
166                            // struct size fits trivially within u32.
167                            #[allow(clippy::cast_possible_truncation)]
168                            cbSize: size_of::<FLASHWINFO>() as UINT,
169                            hwnd,
170                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
171                            uCount: 3,
172                            dwTimeout: 0,
173                        });
174                        break;
175                    }
176                }
177            }
178        });
179    }
180}
181
182/// Sliding-window rate limiter keyed by client IP.
183/// Uses only std primitives — no external crate required.
184struct IpRateLimiter {
185    window: Duration,
186    max_requests: usize,
187    auth_lockout_threshold: u32,
188    auth_lockout_window: Duration,
189    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
190    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
191}
192
193impl IpRateLimiter {
194    fn new(
195        window: Duration,
196        max_requests: usize,
197        auth_lockout_threshold: u32,
198        auth_lockout_window: Duration,
199    ) -> Self {
200        Self {
201            window,
202            max_requests,
203            auth_lockout_threshold,
204            auth_lockout_window,
205            state: std::sync::Mutex::new(HashMap::new()),
206            auth_failures: std::sync::Mutex::new(HashMap::new()),
207        }
208    }
209
210    // The MutexGuard `state` must live as long as `bucket` borrows from it,
211    // so it cannot be dropped any earlier than the end of the inner block.
212    #[allow(clippy::significant_drop_tightening)]
213    fn is_allowed(&self, ip: IpAddr) -> bool {
214        let now = Instant::now();
215        let cutoff = now.checked_sub(self.window).unwrap_or(now);
216        let mut state = self
217            .state
218            .lock()
219            .unwrap_or_else(std::sync::PoisonError::into_inner);
220        if state.len() > 10_000 {
221            state.retain(|_, bucket| {
222                while bucket.front().is_some_and(|t| *t <= cutoff) {
223                    bucket.pop_front();
224                }
225                !bucket.is_empty()
226            });
227        }
228        let bucket = state.entry(ip).or_default();
229        while bucket.front().is_some_and(|t| *t <= cutoff) {
230            bucket.pop_front();
231        }
232        if bucket.len() >= self.max_requests {
233            false
234        } else {
235            bucket.push_back(now);
236            true
237        }
238    }
239
240    fn record_auth_failure(&self, ip: IpAddr) {
241        let now = Instant::now();
242        let mut map = self
243            .auth_failures
244            .lock()
245            .unwrap_or_else(std::sync::PoisonError::into_inner);
246        map.entry(ip)
247            .and_modify(|e| {
248                e.0 += 1;
249                e.1 = now;
250            })
251            .or_insert_with(|| (1, now));
252    }
253
254    fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
255        let mut map = self
256            .auth_failures
257            .lock()
258            .unwrap_or_else(std::sync::PoisonError::into_inner);
259        let expired = map
260            .get(&ip)
261            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
262        if expired {
263            map.remove(&ip);
264            return false;
265        }
266        map.get(&ip)
267            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
268    }
269
270    fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
271        let map = self
272            .auth_failures
273            .lock()
274            .unwrap_or_else(std::sync::PoisonError::into_inner);
275        map.get(&ip).map_or(0, |e| {
276            self.auth_lockout_window
277                .checked_sub(e.1.elapsed())
278                .map_or(0, |r| r.as_secs())
279        })
280    }
281
282    fn spawn_pruning_task(limiter: Arc<Self>) {
283        tokio::spawn(async move {
284            let mut interval = tokio::time::interval(Duration::from_mins(1));
285            interval.tick().await; // consume the immediate first tick
286            loop {
287                interval.tick().await;
288                let now = Instant::now();
289                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
290                {
291                    let mut state = limiter
292                        .state
293                        .lock()
294                        .unwrap_or_else(std::sync::PoisonError::into_inner);
295                    state.retain(|_, bucket| {
296                        while bucket.front().is_some_and(|t| *t <= cutoff) {
297                            bucket.pop_front();
298                        }
299                        !bucket.is_empty()
300                    });
301                }
302                {
303                    let mut auth = limiter
304                        .auth_failures
305                        .lock()
306                        .unwrap_or_else(std::sync::PoisonError::into_inner);
307                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
308                }
309            }
310        });
311    }
312}
313
314/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
315#[derive(Clone, Debug, Default)]
316struct RunResultContext {
317    prev_entry: Option<RegistryEntry>,
318    prev_scan_count: usize,
319    project_path: String,
320}
321
322/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
323#[derive(Clone)]
324enum AsyncRunState {
325    Running {
326        started_at: std::time::Instant,
327        cancel_token: Arc<std::sync::atomic::AtomicBool>,
328    },
329    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
330    Complete {
331        run_id: String,
332    },
333    Failed {
334        message: String,
335    },
336    Cancelled,
337}
338
339/// A saved scan configuration profile — stores the form parameters so users can
340/// re-run a favourite scan with one click.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342struct ScanProfile {
343    id: String,
344    name: String,
345    created_at: String,
346    /// The raw scan-form parameters serialized as JSON.
347    params: serde_json::Value,
348}
349
350#[derive(Debug, Clone, Default, Serialize, Deserialize)]
351struct ScanProfileStore {
352    profiles: Vec<ScanProfile>,
353}
354
355impl ScanProfileStore {
356    fn load(path: &std::path::Path) -> Self {
357        fs::read_to_string(path)
358            .ok()
359            .and_then(|s| serde_json::from_str(&s).ok())
360            .unwrap_or_default()
361    }
362
363    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
364        if let Some(parent) = path.parent() {
365            fs::create_dir_all(parent)?;
366        }
367        let json = serde_json::to_string_pretty(self)?;
368        fs::write(path, json)?;
369        Ok(())
370    }
371}
372
373#[derive(Clone)]
374struct AppState {
375    base_config: AppConfig,
376    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
377    async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
378    registry: Arc<Mutex<ScanRegistry>>,
379    registry_path: PathBuf,
380    analyze_semaphore: Arc<tokio::sync::Semaphore>,
381    server_mode: bool,
382    tls_enabled: bool,
383    api_keys: Vec<secrecy::Secret<String>>,
384    rate_limiter: Arc<IpRateLimiter>,
385    trust_proxy: bool,
386    /// Directory where remote repositories are cloned for git-browser scans.
387    git_clones_dir: PathBuf,
388    /// Persisted list of webhook / poll schedules.
389    schedules: Arc<Mutex<ScheduleStore>>,
390    schedules_path: PathBuf,
391    /// Named scan profiles saved by the user via the web UI.
392    scan_profiles: Arc<Mutex<ScanProfileStore>>,
393    scan_profiles_path: PathBuf,
394    sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
395    /// Persisted Confluence integration settings.
396    confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
397    confluence_path: PathBuf,
398    /// Directories the user has pinned for auto-scanning of external reports.
399    watched_dirs: Arc<Mutex<WatchedDirsStore>>,
400    watched_dirs_path: PathBuf,
401}
402
403type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
404
405/// Parameters for the fire-and-forget HTML + PDF background task.
406
407#[derive(Clone, Debug)]
408pub(crate) struct RunArtifacts {
409    output_dir: PathBuf,
410    html_path: Option<PathBuf>,
411    pdf_path: Option<PathBuf>,
412    json_path: Option<PathBuf>,
413    csv_path: Option<PathBuf>,
414    xlsx_path: Option<PathBuf>,
415    scan_config_path: Option<PathBuf>,
416    report_title: String,
417    result_context: RunResultContext,
418}
419
420#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
421fn build_router(state: AppState) -> Router {
422    // NOSONAR(rust:S3776)
423    let protected = Router::new()
424        .route("/", get(splash))
425        .route("/scan-setup", get(scan_setup_handler))
426        .route("/scan", get(index))
427        .route("/analyze", post(analyze_handler))
428        .route("/preview", get(preview_handler))
429        .route("/api/suggest-coverage", get(api_suggest_coverage))
430        .route("/pick-directory", get(pick_directory_handler))
431        .route("/open-path", get(open_path_handler))
432        .route("/pick-file", get(pick_file_handler))
433        .route("/locate-report", post(locate_report_handler))
434        .route("/locate-reports-dir", post(locate_reports_dir_handler))
435        .route("/relocate-scan", post(relocate_scan_handler))
436        .route("/watched-dirs/add", post(add_watched_dir_handler))
437        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
438        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
439        .route("/view-reports", get(history_handler))
440        .route("/compare-scans", get(compare_select_handler))
441        .route("/compare", get(compare_handler))
442        .route("/images/{folder}/{file}", get(image_handler))
443        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
444        .route("/api/metrics/latest", get(api_metrics_latest_handler))
445        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
446        .route("/api/metrics/history", get(api_metrics_history_handler))
447        .route(
448            "/api/metrics/submodules",
449            get(api_metrics_submodules_handler),
450        )
451        .route("/api/ingest", post(api_ingest_handler))
452        .route("/api/project-history", get(project_history_handler))
453        .route("/trend-reports", get(trend_report_handler))
454        .route("/test-metrics", get(test_metrics_handler))
455        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
456        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
457        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
458        .route("/runs/result/{run_id}", get(async_run_result_handler))
459        .route("/embed/summary", get(embed_handler))
460        // ── Git browser ────────────────────────────────────────────────────────
461        .route("/git-browser", get(git_browser::git_browser_handler))
462        .route("/api/git/refs", get(git_browser::api_list_refs))
463        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
464        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
465        // ── Config export / import ─────────────────────────────────────────────
466        .route("/export-config", get(export_config_handler))
467        .route("/import-config", post(import_config_handler))
468        // ── Scan profiles ──────────────────────────────────────────────────────
469        .route("/api/scan-profiles", get(api_list_scan_profiles))
470        .route("/api/scan-profiles", post(api_save_scan_profile))
471        .route(
472            "/api/scan-profiles/{id}",
473            axum::routing::delete(api_delete_scan_profile),
474        )
475        // ── Integrations (webhooks + Confluence) ──────────────────────────────
476        .route("/integrations", get(integrations::integrations_handler))
477        .route(
478            "/webhook-setup",
479            get(|| async { axum::response::Redirect::permanent("/integrations#webhooks") }),
480        )
481        .route(
482            "/confluence-setup",
483            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
484        )
485        .route("/api/schedules", get(git_webhook::api_list_schedules))
486        .route("/api/schedules", post(git_webhook::api_create_schedule))
487        .route(
488            "/api/schedules",
489            axum::routing::delete(git_webhook::api_delete_schedule),
490        )
491        .route(
492            "/api/confluence/config",
493            get(confluence::api_get_confluence_config),
494        )
495        .route(
496            "/api/confluence/config",
497            post(confluence::api_save_confluence_config),
498        )
499        .route(
500            "/api/confluence/test",
501            post(confluence::api_test_confluence),
502        )
503        .route(
504            "/api/confluence/post",
505            post(confluence::api_post_to_confluence),
506        )
507        .route(
508            "/api/confluence/wiki-markup",
509            get(confluence::api_wiki_markup),
510        )
511        // ── REST API reference page ────────────────────────────────────────────
512        .route("/api-docs", get(api_docs_handler))
513        .route_layer(middleware::from_fn_with_state(
514            state.clone(),
515            require_api_key,
516        ));
517
518    protected
519        .route("/healthz", get(healthz))
520        .route("/badge/{metric}", get(badge_handler))
521        .route("/static/chart.js", get(chart_js_handler))
522        .route("/auth/login", get(auth_login_get))
523        .route("/auth/login", post(auth_login_post))
524        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
525        .route("/webhooks/github", post(git_webhook::handle_github_webhook))
526        .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
527        .route(
528            "/webhooks/bitbucket",
529            post(git_webhook::handle_bitbucket_webhook),
530        )
531        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
532        .layer(middleware::from_fn_with_state(
533            state.clone(),
534            add_security_headers,
535        ))
536        .layer(build_cors_layer(state.server_mode))
537        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
538        .with_state(state)
539}
540
541/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
542pub fn make_test_router() -> Router {
543    let tmp = std::env::temp_dir().join("sloc_test");
544    let state = AppState {
545        base_config: AppConfig::default(),
546        artifacts: Arc::new(Mutex::new(HashMap::new())),
547        async_runs: Arc::new(Mutex::new(HashMap::new())),
548        registry: Arc::new(Mutex::new(ScanRegistry::default())),
549        registry_path: tmp.join("registry.json"),
550        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
551        server_mode: false,
552        tls_enabled: false,
553        api_keys: vec![],
554        rate_limiter: Arc::new(IpRateLimiter::new(
555            Duration::from_mins(1),
556            600,
557            10,
558            Duration::from_hours(1),
559        )),
560        trust_proxy: false,
561        git_clones_dir: tmp.join("git-clones"),
562        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
563        schedules_path: tmp.join("schedules.json"),
564        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
565        scan_profiles_path: tmp.join("scan_profiles.json"),
566        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
567        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
568        confluence_path: tmp.join("confluence_config.json"),
569        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
570        watched_dirs_path: tmp.join("watched_dirs.json"),
571    };
572    build_router(state)
573}
574
575/// # Errors
576///
577/// Returns an error if the server fails to bind to the configured address or
578/// if the TLS configuration cannot be loaded.
579///
580/// # Panics
581///
582/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
583// The function coordinates TLS setup, router construction, and async listener setup in one
584// place; splitting it further would require passing many state values across function boundaries.
585#[allow(clippy::too_many_lines)]
586pub async fn serve(config: AppConfig) -> Result<()> {
587    // NOSONAR(rust:S3776)
588    let bind_address = config.web.bind_address.clone();
589    let server_mode = config.web.server_mode;
590    let output_root = resolve_output_root(None);
591    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
592    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
593        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
594    let mut registry = ScanRegistry::load(&registry_path);
595    registry.prune_stale();
596    let _ = registry.save(&registry_path);
597
598    let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
599        .or_else(|_| std::env::var("SLOC_API_KEY"))
600        .unwrap_or_default()
601        .split(',')
602        .map(str::trim)
603        .filter(|s| !s.is_empty())
604        .map(|s| secrecy::Secret::new(s.to_owned()))
605        .collect();
606    if server_mode && api_keys.is_empty() {
607        println!(
608            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
609             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
610        );
611    }
612
613    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
614    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
615    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
616    if server_mode && !tls_enabled {
617        println!(
618            "WARNING: TLS is not configured. Traffic is cleartext. \
619             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
620             or terminate TLS at a reverse proxy (nginx, caddy)."
621        );
622    }
623    if server_mode {
624        println!(
625            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
626             to restrict cross-origin access (comma-separated)."
627        );
628    }
629    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
630    if trust_proxy {
631        println!(
632            "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
633             Only set this when oxide-sloc is behind a trusted reverse proxy."
634        );
635    }
636
637    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
638        .ok()
639        .and_then(|v| v.parse::<u32>().ok())
640        .unwrap_or(10);
641    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
642        .ok()
643        .and_then(|v| v.parse::<u64>().ok())
644        .unwrap_or(3600);
645    // 600 req/min per IP across all routes (10/sec — suits local/air-gapped use).
646    let rate_limiter = Arc::new(IpRateLimiter::new(
647        Duration::from_mins(1),
648        600,
649        auth_lockout_threshold,
650        Duration::from_secs(auth_lockout_secs),
651    ));
652    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
653
654    let git_clones_dir = resolve_git_clones_dir(&output_root);
655    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
656        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
657    let schedules = ScheduleStore::load(&schedules_path);
658    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
659        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
660    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
661    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
662        |_| output_root.join("confluence_config.json"),
663        PathBuf::from,
664    );
665    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
666    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
667        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
668    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
669
670    let state = AppState {
671        base_config: config,
672        artifacts: Arc::new(Mutex::new(HashMap::new())),
673        async_runs: Arc::new(Mutex::new(HashMap::new())),
674        registry: Arc::new(Mutex::new(registry)),
675        registry_path,
676        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
677        server_mode,
678        tls_enabled,
679        api_keys,
680        rate_limiter,
681        trust_proxy,
682        git_clones_dir,
683        schedules: Arc::new(Mutex::new(schedules)),
684        schedules_path,
685        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
686        scan_profiles_path,
687        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
688        confluence: Arc::new(Mutex::new(confluence)),
689        confluence_path,
690        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
691        watched_dirs_path,
692    };
693
694    restart_poll_schedules(&state).await;
695
696    let app = build_router(state.clone());
697
698    // Try the configured port first, then step up through a few alternatives.
699    // On Windows, a killed process can leave its LISTEN socket as an unkillable
700    // kernel zombie (visible in netstat but owned by no living process).  Rather
701    // than failing, we auto-select the next free port and tell the user.
702    let preferred: SocketAddr = bind_address
703        .parse()
704        .with_context(|| format!("invalid bind address: {bind_address}"))?;
705    let (listener, addr) = {
706        let candidates = (0u16..=9).map(|offset| {
707            let mut a = preferred;
708            a.set_port(preferred.port().saturating_add(offset));
709            a
710        });
711        let mut found = None;
712        for candidate in candidates {
713            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
714                found = Some((l, candidate));
715                break;
716            }
717        }
718        found.ok_or_else(|| {
719            anyhow::anyhow!(
720                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
721                bind_address,
722                preferred.port(),
723                preferred.port().saturating_add(9)
724            )
725        })?
726    };
727    if addr != preferred {
728        eprintln!(
729            "NOTE: port {} is blocked by a system socket (Windows zombie); \
730             using {} instead.",
731            preferred.port(),
732            addr.port()
733        );
734    }
735
736    if tls_enabled {
737        let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
738        let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
739        let tls_config = build_tls_config(&cert_path, &key_path)
740            .context("failed to load TLS certificate/key")?;
741        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
742
743        let url = format!("https://{addr}/");
744        println!("OxideSLOC server running at {url} (TLS)");
745        println!("Use Ctrl+C to stop.");
746
747        return serve_tls(listener, app, acceptor, server_mode).await;
748    }
749
750    let url = format!("http://{addr}/");
751    log_startup_url(&url, server_mode);
752
753    axum::serve(
754        listener,
755        app.into_make_service_with_connect_info::<SocketAddr>(),
756    )
757    .with_graceful_shutdown(shutdown_signal(server_mode))
758    .await
759    .context("web server terminated unexpectedly")
760}
761
762/// Discover the primary non-loopback IPv4 address by asking the OS which
763/// outbound interface it would use to reach a public address.  No packets are
764/// sent — the UDP socket is only used to query the routing table.
765fn primary_lan_ip() -> Option<String> {
766    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
767    socket.connect("8.8.8.8:80").ok()?;
768    let addr = socket.local_addr().ok()?;
769    let ip = addr.ip();
770    if ip.is_loopback() {
771        return None;
772    }
773    Some(ip.to_string())
774}
775
776/// Print the startup URL and, in local mode, open the browser and schedule it.
777fn log_startup_url(url: &str, server_mode: bool) {
778    if server_mode {
779        println!("OxideSLOC server running at {url}");
780        println!("Use Ctrl+C to stop.");
781    } else {
782        println!("OxideSLOC local web UI running at {url}");
783        println!("Press Ctrl+C to stop the server.");
784        let open_url = url.to_owned();
785        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
786    }
787}
788
789/// Open the given URL in the default system browser.
790fn open_browser_tab(url: &str) {
791    #[cfg(target_os = "windows")]
792    let _ = std::process::Command::new("cmd")
793        .args(["/c", "start", "", url])
794        .stdout(Stdio::null())
795        .stderr(Stdio::null())
796        .spawn();
797    #[cfg(target_os = "macos")]
798    let _ = std::process::Command::new("open")
799        .arg(url)
800        .stdout(Stdio::null())
801        .stderr(Stdio::null())
802        .spawn();
803    #[cfg(target_os = "linux")]
804    let _ = std::process::Command::new("xdg-open")
805        .arg(url)
806        .stdout(Stdio::null())
807        .stderr(Stdio::null())
808        .spawn();
809}
810
811/// Graceful-shutdown future: resolves on Ctrl-C.
812async fn shutdown_signal(server_mode: bool) {
813    if tokio::signal::ctrl_c().await.is_ok() {
814        println!();
815        if server_mode {
816            println!("Shutting down OxideSLOC server...");
817        } else {
818            println!("Shutting down OxideSLOC local web UI...");
819        }
820        println!("Server stopped cleanly.");
821    }
822}
823
824/// Load a rustls `ServerConfig` from PEM certificate and key files.
825fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
826    use rustls_pemfile::{certs, private_key};
827    use std::io::BufReader;
828
829    let cert_bytes =
830        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
831    let key_bytes =
832        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
833
834    let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
835        .collect::<std::result::Result<_, _>>()
836        .context("failed to parse TLS certificates")?;
837
838    let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
839        .context("failed to parse TLS private key")?
840        .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
841
842    rustls::ServerConfig::builder()
843        .with_no_client_auth()
844        .with_single_cert(cert_chain, key)
845        .context("failed to build TLS server config")
846}
847
848/// Accept loop with TLS termination using tokio-rustls + hyper-util.
849async fn serve_tls(
850    listener: tokio::net::TcpListener,
851    app: Router,
852    acceptor: tokio_rustls::TlsAcceptor,
853    server_mode: bool,
854) -> Result<()> {
855    use hyper_util::rt::{TokioExecutor, TokioIo};
856    use hyper_util::server::conn::auto::Builder as ConnBuilder;
857    use hyper_util::service::TowerToHyperService;
858    use tower::{Service, ServiceExt};
859
860    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
861
862    loop {
863        tokio::select! {
864            biased;
865            _ = tokio::signal::ctrl_c() => {
866                println!();
867                if server_mode {
868                    println!("Shutting down OxideSLOC server...");
869                } else {
870                    println!("Shutting down OxideSLOC local web UI...");
871                }
872                println!("Server stopped cleanly.");
873                return Ok(());
874            }
875            result = listener.accept() => {
876                let (tcp, peer_addr) = result.context("TLS accept failed")?;
877                let acceptor = acceptor.clone();
878                let mut factory = make_svc.clone();
879
880                tokio::spawn(async move {
881                    let tls = match acceptor.accept(tcp).await {
882                        Ok(s) => s,
883                        Err(e) => {
884                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
885                            return;
886                        }
887                    };
888                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
889                        Ok(f) => match Service::call(f, peer_addr).await {
890                            Ok(s) => s,
891                            Err(_) => return,
892                        },
893                        Err(_) => return,
894                    };
895                    let io = TokioIo::new(tls);
896                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
897                        .serve_connection(io, TowerToHyperService::new(svc))
898                        .await
899                    {
900                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
901                    }
902                });
903            }
904        }
905    }
906}
907
908#[allow(clippy::too_many_lines)] // middleware with multi-path auth logic; extraction is impractical
909async fn require_api_key(
910    // NOSONAR(rust:S3776)
911    State(state): State<AppState>,
912    req: Request<Body>,
913    next: Next,
914) -> Response {
915    if state.api_keys.is_empty() {
916        return next.run(req).await;
917    }
918
919    let keys = &state.api_keys;
920    let peer_ip = req
921        .extensions()
922        .get::<axum::extract::ConnectInfo<SocketAddr>>()
923        .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
924
925    // Collect credentials from all three sources: Bearer header, X-API-Key, session cookie.
926    let auth_header = req
927        .headers()
928        .get(header::AUTHORIZATION)
929        .and_then(|v| v.to_str().ok())
930        .and_then(|v| v.strip_prefix("Bearer "))
931        .map(str::to_owned);
932    let x_api_key = req
933        .headers()
934        .get("X-API-Key")
935        .and_then(|v| v.to_str().ok())
936        .map(str::to_owned);
937    let session_cookie = req
938        .headers()
939        .get(header::COOKIE)
940        .and_then(|v| v.to_str().ok())
941        .and_then(extract_session_cookie)
942        .map(str::to_owned);
943
944    let session_valid = session_cookie.as_deref().is_some_and(|tok| {
945        let now = Instant::now();
946        let mut sessions = state
947            .sessions
948            .lock()
949            .unwrap_or_else(std::sync::PoisonError::into_inner);
950        if let Some(&expiry) = sessions.get(tok) {
951            if now < expiry {
952                return true;
953            }
954            sessions.remove(tok);
955        }
956        false
957    });
958
959    let any_credential_provided =
960        auth_header.is_some() || x_api_key.is_some() || session_cookie.is_some();
961
962    let valid = session_valid
963        || [&auth_header, &x_api_key]
964            .iter()
965            .filter_map(|o| o.as_deref())
966            .any(|k| {
967                keys.iter().any(|expected| {
968                    use secrecy::ExposeSecret;
969                    ct_eq(k, expected.expose_secret())
970                })
971            });
972
973    if valid {
974        return next.run(req).await;
975    }
976
977    if state.rate_limiter.is_auth_locked_out(peer_ip) {
978        tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
979            "Authentication locked out after repeated failures");
980        let remaining = state.rate_limiter.auth_lockout_remaining_secs(peer_ip);
981        let retry_after = HeaderValue::from_str(&remaining.to_string())
982            .unwrap_or(HeaderValue::from_static("3600"));
983        if is_browser_request(&req) {
984            let minutes = remaining.div_ceil(60).max(1);
985            let s = if minutes == 1 { "" } else { "s" };
986            let body = format!(
987                r#"<!doctype html><html><head><meta charset="utf-8">
988<title>Locked Out — OxideSLOC</title>
989<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#2f241c}}
990h1{{color:#b85d33}}p{{line-height:1.6}}code{{background:#f3e9e0;padding:2px 6px;border-radius:4px}}</style>
991</head><body>
992<h1>Too many failed sign-in attempts</h1>
993<p>Access from your IP is temporarily locked. Lockout expires in approximately
994<strong>{minutes} minute{s}</strong>.</p>
995<p>To clear immediately, restart the server.</p>
996<p>For trusted LAN testing, leave <code>SLOC_API_KEY</code> unset, or raise the
997threshold via <code>SLOC_AUTH_LOCKOUT_FAILS</code> / <code>SLOC_AUTH_LOCKOUT_SECS</code>.</p>
998</body></html>"#
999            );
1000            let mut resp = (StatusCode::TOO_MANY_REQUESTS, Html(body)).into_response();
1001            resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
1002            return resp;
1003        }
1004        let mut resp = (
1005            StatusCode::TOO_MANY_REQUESTS,
1006            format!("429 Too Many Requests — locked out, retry in {remaining}s\n"),
1007        )
1008            .into_response();
1009        resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
1010        return resp;
1011    }
1012
1013    if any_credential_provided {
1014        // A credential was supplied but didn't match — record the failure.
1015        state.rate_limiter.record_auth_failure(peer_ip);
1016        let path = req.uri().path().to_owned();
1017        tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
1018            "API key authentication failed");
1019        return (
1020            StatusCode::UNAUTHORIZED,
1021            [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
1022            "401 Unauthorized\n",
1023        )
1024            .into_response();
1025    }
1026
1027    // No credential supplied at all.  Redirect browsers to the login form; return
1028    // a plain 401 for API clients (without recording a failure — unauthenticated
1029    // browser page loads should not burn the lockout counter).
1030    if is_browser_request(&req) {
1031        let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1032        let login_url = format!("/auth/login?next={}", urlencode_path(next_path));
1033        let location = HeaderValue::from_str(&login_url)
1034            .unwrap_or_else(|_| HeaderValue::from_static("/auth/login"));
1035        let mut resp = StatusCode::FOUND.into_response();
1036        resp.headers_mut().insert(header::LOCATION, location);
1037        return resp;
1038    }
1039
1040    (
1041        StatusCode::UNAUTHORIZED,
1042        [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
1043        "401 Unauthorized\n",
1044    )
1045        .into_response()
1046}
1047
1048fn ct_eq(a: &str, b: &str) -> bool {
1049    use subtle::ConstantTimeEq;
1050    a.as_bytes().ct_eq(b.as_bytes()).into()
1051}
1052
1053fn extract_session_cookie(cookie_header: &str) -> Option<&str> {
1054    cookie_header.split(';').find_map(|pair| {
1055        let pair = pair.trim();
1056        let (k, v) = pair.split_once('=')?;
1057        if k.trim() == "sloc_session" {
1058            Some(v.trim())
1059        } else {
1060            None
1061        }
1062    })
1063}
1064
1065fn is_browser_request(req: &Request<Body>) -> bool {
1066    req.headers()
1067        .get(header::ACCEPT)
1068        .and_then(|v| v.to_str().ok())
1069        .is_some_and(|a| a.contains("text/html"))
1070}
1071
1072fn urlencode_path(s: &str) -> String {
1073    let mut out = String::with_capacity(s.len());
1074    for b in s.bytes() {
1075        match b {
1076            b'A'..=b'Z'
1077            | b'a'..=b'z'
1078            | b'0'..=b'9'
1079            | b'-'
1080            | b'_'
1081            | b'.'
1082            | b'~'
1083            | b'/'
1084            | b'?'
1085            | b'='
1086            | b'&'
1087            | b'#' => {
1088                out.push(b as char);
1089            }
1090            _ => {
1091                use std::fmt::Write as _;
1092                write!(&mut out, "%{b:02X}").ok();
1093            }
1094        }
1095    }
1096    out
1097}
1098
1099// ── Login form handlers ────────────────────────────────────────────────────────
1100
1101#[derive(serde::Deserialize)]
1102struct LoginQuery {
1103    next: Option<String>,
1104    error: Option<String>,
1105}
1106
1107#[derive(serde::Deserialize)]
1108struct LoginFormData {
1109    key: String,
1110    next: Option<String>,
1111}
1112
1113async fn auth_login_get(
1114    State(state): State<AppState>,
1115    Query(query): Query<LoginQuery>,
1116    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1117) -> Response {
1118    if state.api_keys.is_empty() {
1119        let mut resp = StatusCode::FOUND.into_response();
1120        resp.headers_mut()
1121            .insert(header::LOCATION, HeaderValue::from_static("/"));
1122        return resp;
1123    }
1124    let has_error = query.error.as_deref() == Some("1");
1125    let next_url = query.next.unwrap_or_default();
1126    let lockout_threshold = state.rate_limiter.auth_lockout_threshold;
1127    Html(
1128        LoginTemplate {
1129            csp_nonce,
1130            has_error,
1131            next_url,
1132            lockout_threshold,
1133        }
1134        .render()
1135        .unwrap_or_else(|e| format!("<pre>Template error: {e}</pre>")),
1136    )
1137    .into_response()
1138}
1139
1140async fn auth_login_post(
1141    State(state): State<AppState>,
1142    axum::extract::ConnectInfo(peer_addr): axum::extract::ConnectInfo<SocketAddr>,
1143    Form(form): Form<LoginFormData>,
1144) -> Response {
1145    let peer_ip = peer_addr.ip();
1146    let next_url = form
1147        .next
1148        .as_deref()
1149        .filter(|s| !s.is_empty())
1150        .unwrap_or("/");
1151    let safe_next = if next_url.starts_with('/') && !next_url.starts_with("//") {
1152        next_url
1153    } else {
1154        "/"
1155    };
1156
1157    let valid = state.api_keys.iter().any(|expected| {
1158        use secrecy::ExposeSecret;
1159        ct_eq(&form.key, expected.expose_secret())
1160    });
1161
1162    if valid {
1163        const SESSION_SECS: u64 = 8 * 3600;
1164        let session_id = uuid::Uuid::new_v4().to_string();
1165        let expiry = Instant::now() + Duration::from_secs(SESSION_SECS);
1166        state
1167            .sessions
1168            .lock()
1169            .unwrap_or_else(std::sync::PoisonError::into_inner)
1170            .insert(session_id.clone(), expiry);
1171        let secure_flag = if state.tls_enabled { "; Secure" } else { "" };
1172        let cookie_value = format!(
1173            "sloc_session={session_id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_SECS}{secure_flag}",
1174        );
1175        let location =
1176            HeaderValue::from_str(safe_next).unwrap_or_else(|_| HeaderValue::from_static("/"));
1177        let cookie_hv = HeaderValue::from_str(&cookie_value)
1178            .unwrap_or_else(|_| HeaderValue::from_static("sloc_session=; Path=/; HttpOnly"));
1179        let mut resp = StatusCode::FOUND.into_response();
1180        resp.headers_mut().insert(header::LOCATION, location);
1181        resp.headers_mut().insert(header::SET_COOKIE, cookie_hv);
1182        resp
1183    } else {
1184        state.rate_limiter.record_auth_failure(peer_ip);
1185        tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = "/auth/login",
1186            "Login form authentication failed");
1187        let error_url = format!("/auth/login?next={}&error=1", urlencode_path(safe_next));
1188        let location = HeaderValue::from_str(&error_url)
1189            .unwrap_or_else(|_| HeaderValue::from_static("/auth/login?error=1"));
1190        let mut resp = StatusCode::FOUND.into_response();
1191        resp.headers_mut().insert(header::LOCATION, location);
1192        resp
1193    }
1194}
1195
1196fn build_cors_layer(server_mode: bool) -> CorsLayer {
1197    if server_mode {
1198        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1199            .unwrap_or_default()
1200            .split(',')
1201            .filter(|s| !s.is_empty())
1202            .filter_map(|s| s.trim().parse().ok())
1203            .collect();
1204        if allowed.is_empty() {
1205            return CorsLayer::new();
1206        }
1207        CorsLayer::new()
1208            .allow_origin(AllowOrigin::list(allowed))
1209            .allow_methods(AllowMethods::list([
1210                axum::http::Method::GET,
1211                axum::http::Method::POST,
1212            ]))
1213            .allow_headers(AllowHeaders::list([
1214                axum::http::header::AUTHORIZATION,
1215                axum::http::header::CONTENT_TYPE,
1216            ]))
1217    } else {
1218        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1219            let s = origin.to_str().unwrap_or("");
1220            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1221        }))
1222    }
1223}
1224
1225async fn add_security_headers(
1226    State(state): State<AppState>,
1227    mut req: Request<Body>,
1228    next: Next,
1229) -> Response {
1230    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1231    req.extensions_mut().insert(CspNonce(nonce.clone()));
1232    let mut resp = next.run(req).await;
1233    let h = resp.headers_mut();
1234    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1235    h.insert(
1236        "X-Content-Type-Options",
1237        HeaderValue::from_static("nosniff"),
1238    );
1239    h.insert(
1240        "Referrer-Policy",
1241        HeaderValue::from_static("strict-origin-when-cross-origin"),
1242    );
1243    let csp = format!(
1244        "default-src 'self'; \
1245         style-src 'self' 'unsafe-inline'; \
1246         img-src 'self' data: blob:; \
1247         script-src 'self' 'nonce-{nonce}'; \
1248         font-src 'self' data:; \
1249         object-src 'none'; \
1250         frame-ancestors 'none'"
1251    );
1252    h.insert(
1253        "Content-Security-Policy",
1254        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1255            HeaderValue::from_static(
1256                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1257            )
1258        }),
1259    );
1260    h.insert(
1261        "X-Permitted-Cross-Domain-Policies",
1262        HeaderValue::from_static("none"),
1263    );
1264    h.insert(
1265        "Permissions-Policy",
1266        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1267    );
1268    h.insert(
1269        "Cross-Origin-Opener-Policy",
1270        HeaderValue::from_static("same-origin"),
1271    );
1272    h.insert(
1273        "Cross-Origin-Resource-Policy",
1274        HeaderValue::from_static("same-origin"),
1275    );
1276    if state.tls_enabled {
1277        h.insert(
1278            "Strict-Transport-Security",
1279            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1280        );
1281    }
1282    resp
1283}
1284
1285async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1286    let ip = req
1287        .extensions()
1288        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1289        .map(|c| c.0.ip())
1290        .or_else(|| {
1291            if state.trust_proxy {
1292                req.headers()
1293                    .get("X-Forwarded-For")
1294                    .and_then(|v| v.to_str().ok())
1295                    .and_then(|s| s.split(',').next())
1296                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1297            } else {
1298                None
1299            }
1300        })
1301        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1302
1303    if !state.rate_limiter.is_allowed(ip) {
1304        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1305            path = %req.uri().path(), "Rate limit exceeded");
1306        return (
1307            StatusCode::TOO_MANY_REQUESTS,
1308            [(header::RETRY_AFTER, "60")],
1309            "429 Too Many Requests\n",
1310        )
1311            .into_response();
1312    }
1313    next.run(req).await
1314}
1315
1316async fn splash(
1317    State(state): State<AppState>,
1318    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1319) -> impl IntoResponse {
1320    let lan_ip = if state.server_mode {
1321        primary_lan_ip()
1322    } else {
1323        None
1324    };
1325    let port = state
1326        .base_config
1327        .web
1328        .bind_address
1329        .rsplit(':')
1330        .next()
1331        .and_then(|p| p.parse::<u16>().ok())
1332        .unwrap_or(4317);
1333    let template = SplashTemplate {
1334        csp_nonce,
1335        server_mode: state.server_mode,
1336        lan_ip,
1337        port,
1338        version: env!("CARGO_PKG_VERSION"),
1339    };
1340    Html(
1341        template
1342            .render()
1343            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1344    )
1345}
1346
1347async fn index(
1348    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1349    Query(query): Query<IndexQuery>,
1350) -> impl IntoResponse {
1351    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1352        let policy = query
1353            .mixed_line_policy
1354            .unwrap_or_else(|| "code_only".to_string());
1355        let behavior = query
1356            .binary_file_behavior
1357            .unwrap_or_else(|| "skip".to_string());
1358        let cfg = ScanConfig {
1359            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1360            path: query.path.unwrap_or_default(),
1361            include_globs: query.include_globs.unwrap_or_default(),
1362            exclude_globs: query.exclude_globs.unwrap_or_default(),
1363            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1364            mixed_line_policy: policy,
1365            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1366                != Some("off"),
1367            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1368            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1369            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1370                != Some("disabled"),
1371            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1372            binary_file_behavior: behavior,
1373            output_dir: query.output_dir.unwrap_or_default(),
1374            report_title: query.report_title.unwrap_or_default(),
1375            generate_html: query.generate_html.as_deref() != Some("off"),
1376            generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1377        };
1378        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1379    } else {
1380        "{}".to_string()
1381    };
1382
1383    let git_repo = query.git_repo.unwrap_or_default();
1384    let git_ref = query.git_ref.unwrap_or_default();
1385
1386    let git_label = make_git_label(&git_repo, &git_ref);
1387    let git_output_dir = if git_label.is_empty() {
1388        String::new()
1389    } else {
1390        desktop_dir().join(&git_label).display().to_string()
1391    };
1392    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1393    let git_output_dir_json =
1394        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1395
1396    let template = IndexTemplate {
1397        version: env!("CARGO_PKG_VERSION"),
1398        prefill_json,
1399        csp_nonce,
1400        git_repo,
1401        git_ref,
1402        git_label_json,
1403        git_output_dir_json,
1404    };
1405
1406    Html(
1407        template
1408            .render()
1409            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1410    )
1411}
1412
1413async fn scan_setup_handler(
1414    State(state): State<AppState>,
1415    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1416) -> impl IntoResponse {
1417    let recent_scans_json = {
1418        let arr: Vec<serde_json::Value> = {
1419            let reg = state.registry.lock().await;
1420            reg.entries
1421                .iter()
1422                .rev()
1423                .take(6)
1424                .map(|e| {
1425                    let run_dir = e
1426                        .html_path
1427                        .as_ref()
1428                        .or(e.json_path.as_ref())
1429                        .and_then(|p| p.parent().map(PathBuf::from));
1430                    let config_val: Option<serde_json::Value> = run_dir
1431                        .and_then(|d| find_scan_config_in_dir(&d))
1432                        .and_then(|p| fs::read_to_string(&p).ok())
1433                        .and_then(|s| serde_json::from_str(&s).ok());
1434                    serde_json::json!({
1435                        "project_label": e.project_label,
1436                        "timestamp": fmt_la_time(e.timestamp_utc),
1437                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1438                        "config": config_val,
1439                    })
1440                })
1441                .collect()
1442        };
1443        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1444    };
1445
1446    let template = ScanSetupTemplate {
1447        version: env!("CARGO_PKG_VERSION"),
1448        recent_scans_json,
1449        csp_nonce,
1450    };
1451    Html(
1452        template
1453            .render()
1454            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1455    )
1456}
1457
1458async fn healthz() -> &'static str {
1459    "ok"
1460}
1461
1462async fn api_docs_handler(
1463    State(state): State<AppState>,
1464    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1465) -> impl IntoResponse {
1466    let has_api_key = !state.api_keys.is_empty();
1467    Html(
1468        ApiDocsTemplate {
1469            has_api_key,
1470            csp_nonce,
1471            version: env!("CARGO_PKG_VERSION"),
1472        }
1473        .render()
1474        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1475    )
1476}
1477
1478async fn chart_js_handler() -> impl IntoResponse {
1479    (
1480        [(
1481            header::CONTENT_TYPE,
1482            "application/javascript; charset=utf-8",
1483        )],
1484        CHART_JS,
1485    )
1486}
1487
1488#[derive(Debug, Deserialize)]
1489struct AnalyzeForm {
1490    path: String,
1491    git_repo: Option<String>,
1492    git_ref: Option<String>,
1493    mixed_line_policy: Option<MixedLinePolicy>,
1494    python_docstrings_as_comments: Option<String>,
1495    generated_file_detection: Option<String>,
1496    minified_file_detection: Option<String>,
1497    vendor_directory_detection: Option<String>,
1498    include_lockfiles: Option<String>,
1499    binary_file_behavior: Option<BinaryFileBehavior>,
1500    output_dir: Option<String>,
1501    report_title: Option<String>,
1502    report_header_footer: Option<String>,
1503    generate_html: Option<String>,
1504    generate_pdf: Option<String>,
1505    include_globs: Option<String>,
1506    exclude_globs: Option<String>,
1507    submodule_breakdown: Option<String>,
1508    coverage_file: Option<String>,
1509}
1510
1511#[allow(clippy::struct_excessive_bools)]
1512#[derive(Debug, Serialize, Deserialize, Clone)]
1513struct ScanConfig {
1514    oxide_sloc_version: String,
1515    path: String,
1516    include_globs: String,
1517    exclude_globs: String,
1518    submodule_breakdown: bool,
1519    mixed_line_policy: String,
1520    python_docstrings_as_comments: bool,
1521    generated_file_detection: bool,
1522    minified_file_detection: bool,
1523    vendor_directory_detection: bool,
1524    include_lockfiles: bool,
1525    binary_file_behavior: String,
1526    output_dir: String,
1527    report_title: String,
1528    generate_html: bool,
1529    generate_pdf: bool,
1530}
1531
1532#[derive(Debug, Deserialize, Default)]
1533struct IndexQuery {
1534    path: Option<String>,
1535    include_globs: Option<String>,
1536    exclude_globs: Option<String>,
1537    submodule_breakdown: Option<String>,
1538    mixed_line_policy: Option<String>,
1539    python_docstrings_as_comments: Option<String>,
1540    generated_file_detection: Option<String>,
1541    minified_file_detection: Option<String>,
1542    vendor_directory_detection: Option<String>,
1543    include_lockfiles: Option<String>,
1544    binary_file_behavior: Option<String>,
1545    output_dir: Option<String>,
1546    report_title: Option<String>,
1547    generate_html: Option<String>,
1548    generate_pdf: Option<String>,
1549    prefilled: Option<String>,
1550    git_repo: Option<String>,
1551    git_ref: Option<String>,
1552}
1553
1554#[derive(Debug, Deserialize)]
1555struct PreviewQuery {
1556    path: Option<String>,
1557    include_globs: Option<String>,
1558    exclude_globs: Option<String>,
1559}
1560
1561#[cfg(feature = "native-dialog")]
1562#[derive(Debug, Deserialize)]
1563struct PickDirectoryQuery {
1564    kind: Option<String>,
1565    current: Option<String>,
1566}
1567
1568#[cfg(not(feature = "native-dialog"))]
1569#[derive(Debug, Deserialize)]
1570struct PickDirectoryQuery {}
1571
1572#[derive(Debug, Deserialize, Default)]
1573struct ArtifactQuery {
1574    download: Option<String>,
1575}
1576
1577#[cfg(feature = "native-dialog")]
1578#[derive(Debug, Serialize)]
1579struct PickDirectoryResponse {
1580    selected_path: Option<String>,
1581    cancelled: bool,
1582}
1583
1584#[cfg(feature = "native-dialog")]
1585async fn pick_directory_handler(
1586    State(state): State<AppState>,
1587    Query(query): Query<PickDirectoryQuery>,
1588) -> Response {
1589    if state.server_mode {
1590        return StatusCode::NOT_FOUND.into_response();
1591    }
1592
1593    let is_coverage = query.kind.as_deref() == Some("coverage");
1594    let title = match query.kind.as_deref() {
1595        Some("output") => "Select output directory",
1596        Some("reports") => "Select folder containing saved reports",
1597        Some("coverage") => "Select LCOV coverage file",
1598        _ => "Select project directory",
1599    }
1600    .to_owned();
1601    let current = query.current.clone();
1602
1603    let picked = tokio::task::spawn_blocking(move || {
1604        // Windows: attach to the foreground thread so the dialog inherits focus,
1605        // and kick off a watcher that flashes the dialog once it appears.
1606        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1607        let fg_tid = win_dialog_focus::attach_to_foreground();
1608        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1609        win_dialog_focus::flash_dialog_when_ready(title.clone());
1610
1611        let mut dialog = rfd::FileDialog::new().set_title(&title);
1612        if let Some(current) = current.as_deref() {
1613            let resolved = resolve_input_path(current);
1614            let seed = if resolved.is_dir() {
1615                Some(resolved)
1616            } else {
1617                resolved.parent().map(Path::to_path_buf)
1618            };
1619            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1620                dialog = dialog.set_directory(seed_dir);
1621            }
1622        }
1623        let result = if is_coverage {
1624            dialog
1625                .add_filter(
1626                    "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1627                    &["info", "lcov", "xml"],
1628                )
1629                .pick_file()
1630        } else {
1631            dialog.pick_folder()
1632        };
1633
1634        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1635        win_dialog_focus::detach_from_foreground(fg_tid);
1636
1637        result
1638    })
1639    .await
1640    .unwrap_or(None);
1641
1642    Json(PickDirectoryResponse {
1643        selected_path: picked.as_ref().map(|p| display_path(p)),
1644        cancelled: picked.is_none(),
1645    })
1646    .into_response()
1647}
1648
1649#[cfg(not(feature = "native-dialog"))]
1650async fn pick_directory_handler(
1651    State(_state): State<AppState>,
1652    Query(_query): Query<PickDirectoryQuery>,
1653) -> Response {
1654    StatusCode::NOT_FOUND.into_response()
1655}
1656
1657#[cfg(feature = "native-dialog")]
1658async fn pick_file_handler(State(state): State<AppState>) -> Response {
1659    if state.server_mode {
1660        return StatusCode::NOT_FOUND.into_response();
1661    }
1662    let picked = tokio::task::spawn_blocking(|| {
1663        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1664        let fg_tid = win_dialog_focus::attach_to_foreground();
1665        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1666        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1667
1668        let result = rfd::FileDialog::new()
1669            .set_title("Select HTML report")
1670            .add_filter("HTML report", &["html"])
1671            .pick_file();
1672
1673        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1674        win_dialog_focus::detach_from_foreground(fg_tid);
1675
1676        result
1677    })
1678    .await
1679    .unwrap_or(None);
1680    Json(PickDirectoryResponse {
1681        selected_path: picked.as_ref().map(|p| display_path(p)),
1682        cancelled: picked.is_none(),
1683    })
1684    .into_response()
1685}
1686
1687#[cfg(not(feature = "native-dialog"))]
1688async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1689    StatusCode::NOT_FOUND.into_response()
1690}
1691
1692#[derive(Deserialize)]
1693struct LocateReportForm {
1694    file_path: String,
1695}
1696
1697/// Render a view-reports error page and return it as a `Response`.
1698fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
1699    let html = ErrorTemplate {
1700        message: message.into(),
1701        last_report_url: Some("/view-reports".to_string()),
1702        last_report_label: Some("View Reports".to_string()),
1703        csp_nonce: csp_nonce.to_owned(),
1704    }
1705    .render()
1706    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1707    Html(html).into_response()
1708}
1709
1710/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
1711fn registry_entry_from_run(
1712    run: &AnalysisRun,
1713    json_path: PathBuf,
1714    html_path: PathBuf,
1715) -> RegistryEntry {
1716    let project_label = run.input_roots.first().map_or_else(
1717        || "Unknown Project".to_string(),
1718        |r| sanitize_project_label(r),
1719    );
1720    RegistryEntry {
1721        run_id: run.tool.run_id.clone(),
1722        timestamp_utc: run.tool.timestamp_utc,
1723        project_label,
1724        input_roots: run.input_roots.clone(),
1725        json_path: Some(json_path),
1726        html_path: Some(html_path),
1727        pdf_path: None,
1728        summary: ScanSummarySnapshot {
1729            files_analyzed: run.summary_totals.files_analyzed,
1730            files_skipped: run.summary_totals.files_skipped,
1731            total_physical_lines: run.summary_totals.total_physical_lines,
1732            code_lines: run.summary_totals.code_lines,
1733            comment_lines: run.summary_totals.comment_lines,
1734            blank_lines: run.summary_totals.blank_lines,
1735            functions: run.summary_totals.functions,
1736            classes: run.summary_totals.classes,
1737            variables: run.summary_totals.variables,
1738            imports: run.summary_totals.imports,
1739            test_count: run.summary_totals.test_count,
1740        },
1741        csv_path: None,
1742        xlsx_path: None,
1743        git_branch: None,
1744        git_commit: None,
1745        git_author: None,
1746        git_tags: None,
1747        git_nearest_tag: None,
1748        git_commit_date: None,
1749    }
1750}
1751
1752/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
1753/// immediately without requiring a server restart.
1754pub(crate) async fn register_artifacts_in_registry(
1755    state: &AppState,
1756    label: &str,
1757    run: &AnalysisRun,
1758    artifacts: &RunArtifacts,
1759) {
1760    let Some(json_path) = artifacts.json_path.clone() else {
1761        return;
1762    };
1763    let Some(html_path) = artifacts.html_path.clone() else {
1764        return;
1765    };
1766    let mut entry = registry_entry_from_run(run, json_path, html_path);
1767    entry.project_label = label.to_owned();
1768    let mut reg = state.registry.lock().await;
1769    reg.add_entry(entry);
1770    let _ = reg.save(&state.registry_path);
1771}
1772
1773/// Validate the locate-report form: check extension, resolve the canonical path, enforce
1774/// server-mode root restriction, and extract the parent directory.
1775///
1776/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
1777#[allow(clippy::result_large_err)]
1778fn validate_locate_request(
1779    state: &AppState,
1780    file_path: &str,
1781    csp_nonce: &str,
1782) -> Result<(PathBuf, PathBuf), Response> {
1783    let file_ext = Path::new(file_path)
1784        .extension()
1785        .and_then(|e| e.to_str())
1786        .unwrap_or("")
1787        .to_ascii_lowercase();
1788    if file_ext != "html" {
1789        return Err(locate_report_error(
1790            "Only .html report files can be located via this form.",
1791            csp_nonce,
1792        ));
1793    }
1794    let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1795        Ok(p) => strip_unc_prefix(p),
1796        Err(_) => {
1797            return Err(locate_report_error(
1798                "Report file not found or path is invalid.",
1799                csp_nonce,
1800            ));
1801        }
1802    };
1803    if state.server_mode {
1804        let output_root = resolve_output_root(None);
1805        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1806        if !html_path.starts_with(&canonical_root) {
1807            return Err(locate_report_error(
1808                "Report file must be within the configured output directory.",
1809                csp_nonce,
1810            ));
1811        }
1812    }
1813    let parent = match html_path.parent() {
1814        Some(p) => p.to_path_buf(),
1815        None => {
1816            return Err(locate_report_error(
1817                "Report file has no parent directory.",
1818                csp_nonce,
1819            ));
1820        }
1821    };
1822    Ok((html_path, parent))
1823}
1824
1825/// Return a non-sensitive path hint for error messages (empty in server mode).
1826fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1827    if server_mode {
1828        String::new()
1829    } else {
1830        format!("\n\nFile: {}", path.display())
1831    }
1832}
1833
1834async fn locate_report_handler(
1835    State(state): State<AppState>,
1836    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1837    Form(form): Form<LocateReportForm>,
1838) -> impl IntoResponse {
1839    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1840        Ok(v) => v,
1841        Err(resp) => return resp,
1842    };
1843
1844    let json_candidate = parent.join("result.json");
1845    let mut reg = state.registry.lock().await;
1846    // Find an existing entry whose output directory matches the selected file's parent.
1847    let entry_idx = reg.entries.iter().position(|e| {
1848        let json_match = e
1849            .json_path
1850            .as_ref()
1851            .and_then(|p| p.parent())
1852            .is_some_and(|p| p == parent);
1853        let html_match = e
1854            .html_path
1855            .as_ref()
1856            .and_then(|p| p.parent())
1857            .is_some_and(|p| p == parent);
1858        json_match || html_match
1859    });
1860    if let Some(idx) = entry_idx {
1861        reg.entries[idx].html_path = Some(html_path);
1862        let _ = reg.save(&state.registry_path);
1863        return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1864    }
1865    // No match — attempt to build an entry from an adjacent result.json.
1866    if json_candidate.exists() {
1867        match read_json(&json_candidate) {
1868            Ok(run) => {
1869                let entry = registry_entry_from_run(&run, json_candidate, html_path);
1870                reg.add_entry(entry);
1871                let _ = reg.save(&state.registry_path);
1872                return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1873            }
1874            Err(e) => {
1875                let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1876                let err_detail = if state.server_mode {
1877                    String::new()
1878                } else {
1879                    format!("\n\nError: {e}")
1880                };
1881                return locate_report_error(
1882                    format!(
1883                        "Could not link this report.\n\nA 'result.json' was found but could not \
1884                         be parsed — it may have been saved by an older version of OxideSLOC. \
1885                         Re-running the analysis will create a fresh, compatible \
1886                         record.{file_hint}{err_detail}"
1887                    ),
1888                    &csp_nonce,
1889                );
1890            }
1891        }
1892    }
1893    drop(reg);
1894    let file_hint = locate_path_hint(state.server_mode, &html_path);
1895    locate_report_error(
1896        format!(
1897            "Could not link this report.\n\nNo matching scan record was found, and no \
1898             'result.json' was found in the same folder.{file_hint}"
1899        ),
1900        &csp_nonce,
1901    )
1902}
1903
1904/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
1905fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
1906    fs::read_dir(dir)
1907        .ok()?
1908        .flatten()
1909        .map(|e| e.path())
1910        .find(|p| {
1911            p.is_file()
1912                && p.file_stem()
1913                    .and_then(|n| n.to_str())
1914                    .is_some_and(|n| n.starts_with("result"))
1915                && p.extension()
1916                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
1917        })
1918}
1919
1920#[derive(Deserialize)]
1921struct LocateReportsDirForm {
1922    folder_path: String,
1923}
1924
1925#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
1926async fn locate_reports_dir_handler(
1927    // NOSONAR(rust:S3776)
1928    State(state): State<AppState>,
1929    Form(form): Form<LocateReportsDirForm>,
1930) -> impl IntoResponse {
1931    if state.server_mode {
1932        return StatusCode::NOT_FOUND.into_response();
1933    }
1934    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
1935        Ok(p) => strip_unc_prefix(p),
1936        Err(_) => {
1937            return axum::response::Redirect::to(
1938                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
1939            )
1940            .into_response();
1941        }
1942    };
1943    if !folder.is_dir() {
1944        return axum::response::Redirect::to(
1945            "/view-reports?error=Selected+path+is+not+a+directory.",
1946        )
1947        .into_response();
1948    }
1949
1950    // Collect result*.json candidates: the folder itself and one level of subdirectories.
1951    // Filenames use the pattern result_<project>_<commit>.json — match by prefix/suffix.
1952    let mut candidates: Vec<PathBuf> = Vec::new();
1953    if let Some(j) = find_result_json_in_dir(&folder) {
1954        candidates.push(j);
1955    }
1956    if let Ok(dir_entries) = fs::read_dir(&folder) {
1957        for entry in dir_entries.flatten() {
1958            let sub = entry.path();
1959            if sub.is_dir() {
1960                if let Some(j) = find_result_json_in_dir(&sub) {
1961                    candidates.push(j);
1962                }
1963            }
1964        }
1965    }
1966
1967    if candidates.is_empty() {
1968        return axum::response::Redirect::to(
1969            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
1970        )
1971        .into_response();
1972    }
1973
1974    let mut linked_count: usize = 0;
1975    let mut reg = state.registry.lock().await;
1976    for json_path in candidates {
1977        let parent = match json_path.parent() {
1978            Some(p) => p.to_path_buf(),
1979            None => continue,
1980        };
1981        // Skip if this directory is already registered AND the artifact still exists on disk.
1982        // A stale entry (file moved/deleted) must not block re-scanning the same directory.
1983        let already = reg.entries.iter().any(|e| {
1984            let dir_match = e
1985                .json_path
1986                .as_ref()
1987                .and_then(|p| p.parent())
1988                .is_some_and(|p| p == parent)
1989                || e.html_path
1990                    .as_ref()
1991                    .and_then(|p| p.parent())
1992                    .is_some_and(|p| p == parent);
1993            dir_match
1994                && (e.json_path.as_ref().is_some_and(|p| p.exists())
1995                    || e.html_path.as_ref().is_some_and(|p| p.exists()))
1996        });
1997        if already {
1998            continue;
1999        }
2000        // Find the first .html file in the same directory.
2001        let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2002            rd.flatten()
2003                .map(|e| e.path())
2004                .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2005        });
2006        let Ok(run) = read_json(&json_path) else {
2007            continue;
2008        };
2009        let project_label = run.input_roots.first().map_or_else(
2010            || "Unknown Project".to_string(),
2011            |r| sanitize_project_label(r),
2012        );
2013        let entry = RegistryEntry {
2014            run_id: run.tool.run_id.clone(),
2015            timestamp_utc: run.tool.timestamp_utc,
2016            project_label,
2017            input_roots: run.input_roots.clone(),
2018            json_path: Some(json_path),
2019            html_path,
2020            pdf_path: None,
2021            csv_path: None,
2022            xlsx_path: None,
2023            summary: ScanSummarySnapshot {
2024                files_analyzed: run.summary_totals.files_analyzed,
2025                files_skipped: run.summary_totals.files_skipped,
2026                total_physical_lines: run.summary_totals.total_physical_lines,
2027                code_lines: run.summary_totals.code_lines,
2028                comment_lines: run.summary_totals.comment_lines,
2029                blank_lines: run.summary_totals.blank_lines,
2030                functions: run.summary_totals.functions,
2031                classes: run.summary_totals.classes,
2032                variables: run.summary_totals.variables,
2033                imports: run.summary_totals.imports,
2034                test_count: run.summary_totals.test_count,
2035            },
2036            git_branch: run.git_branch.clone(),
2037            git_commit: run.git_commit_short.clone(),
2038            git_author: run.git_commit_author.clone(),
2039            git_tags: run.git_tags.clone(),
2040            git_nearest_tag: run.git_nearest_tag.clone(),
2041            git_commit_date: run.git_commit_date.clone(),
2042        };
2043        reg.add_entry(entry);
2044        linked_count += 1;
2045    }
2046    let _ = reg.save(&state.registry_path);
2047    drop(reg);
2048
2049    if linked_count == 0 {
2050        return axum::response::Redirect::to(
2051            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2052        )
2053        .into_response();
2054    }
2055    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2056}
2057
2058#[derive(Deserialize)]
2059struct RelocateScanForm {
2060    run_id: String,
2061    folder_path: String,
2062    redirect_url: String,
2063}
2064
2065#[allow(clippy::too_many_lines)] // scan relocation handler with inline HTML rendering
2066async fn relocate_scan_handler(
2067    // NOSONAR(rust:S3776)
2068    State(state): State<AppState>,
2069    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2070    Form(form): Form<RelocateScanForm>,
2071) -> impl IntoResponse {
2072    if state.server_mode {
2073        return StatusCode::NOT_FOUND.into_response();
2074    }
2075
2076    let run_id = form.run_id.trim().to_string();
2077    let redirect_url = form.redirect_url.trim().to_string();
2078
2079    let run_exists = {
2080        let reg = state.registry.lock().await;
2081        reg.find_by_run_id(&run_id).is_some()
2082    };
2083    if !run_exists {
2084        let html = ErrorTemplate {
2085            message: format!("Run ID '{run_id}' not found in registry."),
2086            last_report_url: Some("/compare-scans".to_string()),
2087            last_report_label: Some("Compare Scans".to_string()),
2088            csp_nonce: csp_nonce.clone(),
2089        }
2090        .render()
2091        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2092        return Html(html).into_response();
2093    }
2094
2095    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2096        Ok(p) => strip_unc_prefix(p),
2097        Err(_) => {
2098            return missing_scan_relocate_response(
2099                "Folder not found or path is invalid.",
2100                &run_id,
2101                form.folder_path.trim(),
2102                &redirect_url,
2103                false,
2104                &csp_nonce,
2105            );
2106        }
2107    };
2108
2109    if !folder.is_dir() {
2110        return missing_scan_relocate_response(
2111            "Selected path is not a directory.",
2112            &run_id,
2113            &folder.display().to_string(),
2114            &redirect_url,
2115            false,
2116            &csp_nonce,
2117        );
2118    }
2119
2120    let json_candidates: Vec<PathBuf> = fs::read_dir(&folder)
2121        .ok()
2122        .into_iter()
2123        .flatten()
2124        .flatten()
2125        .map(|e| e.path())
2126        .filter(|p| {
2127            p.is_file()
2128                && p.file_stem()
2129                    .and_then(|n| n.to_str())
2130                    .is_some_and(|n| n.starts_with("result"))
2131                && p.extension()
2132                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2133        })
2134        .collect();
2135
2136    if json_candidates.is_empty() {
2137        return missing_scan_relocate_response(
2138            &format!(
2139                "No result JSON files found in the selected folder.\nSearched: {}",
2140                folder.display()
2141            ),
2142            &run_id,
2143            &folder.display().to_string(),
2144            &redirect_url,
2145            false,
2146            &csp_nonce,
2147        );
2148    }
2149
2150    let mut matched_json: Option<PathBuf> = None;
2151    for candidate in &json_candidates {
2152        if let Ok(run) = read_json(candidate) {
2153            if run.tool.run_id == run_id {
2154                matched_json = Some(candidate.clone());
2155                break;
2156            }
2157        }
2158    }
2159
2160    let Some(json_path) = matched_json else {
2161        return missing_scan_relocate_response(
2162            &format!(
2163                "No matching scan found in the selected folder.\n\
2164                 The JSON files present do not contain run ID: {run_id}\n\
2165                 Searched: {}",
2166                folder.display()
2167            ),
2168            &run_id,
2169            &folder.display().to_string(),
2170            &redirect_url,
2171            false,
2172            &csp_nonce,
2173        );
2174    };
2175
2176    let html_path = fs::read_dir(&folder)
2177        .ok()
2178        .into_iter()
2179        .flatten()
2180        .flatten()
2181        .map(|e| e.path())
2182        .find(|p| {
2183            p.is_file()
2184                && p.file_stem()
2185                    .and_then(|n| n.to_str())
2186                    .is_some_and(|n| n.starts_with("result"))
2187                && p.extension()
2188                    .is_some_and(|e| e.eq_ignore_ascii_case("html"))
2189        });
2190    let pdf_path = fs::read_dir(&folder)
2191        .ok()
2192        .into_iter()
2193        .flatten()
2194        .flatten()
2195        .map(|e| e.path())
2196        .find(|p| {
2197            p.is_file()
2198                && p.file_stem()
2199                    .and_then(|n| n.to_str())
2200                    .is_some_and(|n| n.starts_with("result"))
2201                && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
2202        });
2203
2204    {
2205        let mut reg = state.registry.lock().await;
2206        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2207            entry.json_path = Some(json_path);
2208            if let Some(hp) = html_path {
2209                entry.html_path = Some(hp);
2210            }
2211            if let Some(pp) = pdf_path {
2212                entry.pdf_path = Some(pp);
2213            }
2214        }
2215        let _ = reg.save(&state.registry_path);
2216    }
2217
2218    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
2219        redirect_url
2220    } else {
2221        "/compare-scans".to_string()
2222    };
2223    axum::response::Redirect::to(&safe_redirect).into_response()
2224}
2225
2226fn missing_scan_relocate_response(
2227    message: &str,
2228    run_id: &str,
2229    folder_hint: &str,
2230    redirect_url: &str,
2231    server_mode: bool,
2232    csp_nonce: &str,
2233) -> axum::response::Response {
2234    let html = RelocateScanTemplate {
2235        message: message.to_string(),
2236        run_id: run_id.to_string(),
2237        folder_hint: folder_hint.to_string(),
2238        redirect_url: redirect_url.to_string(),
2239        server_mode,
2240        csp_nonce: csp_nonce.to_owned(),
2241    }
2242    .render()
2243    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2244    (StatusCode::NOT_FOUND, Html(html)).into_response()
2245}
2246
2247// ── Watched-directory helpers ─────────────────────────────────────────────────
2248
2249/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
2250/// Returns the number of newly linked entries.
2251fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
2252    let mut candidates: Vec<PathBuf> = Vec::new();
2253    if let Some(j) = find_result_json_in_dir(folder) {
2254        candidates.push(j);
2255    }
2256    if let Ok(dir_entries) = fs::read_dir(folder) {
2257        for entry in dir_entries.flatten() {
2258            let sub = entry.path();
2259            if sub.is_dir() {
2260                if let Some(j) = find_result_json_in_dir(&sub) {
2261                    candidates.push(j);
2262                }
2263            }
2264        }
2265    }
2266
2267    let mut linked = 0usize;
2268    for json_path in candidates {
2269        let parent = match json_path.parent() {
2270            Some(p) => p.to_path_buf(),
2271            None => continue,
2272        };
2273        let already = reg.entries.iter().any(|e| {
2274            let dir_match = e
2275                .json_path
2276                .as_ref()
2277                .and_then(|p| p.parent())
2278                .is_some_and(|p| p == parent)
2279                || e.html_path
2280                    .as_ref()
2281                    .and_then(|p| p.parent())
2282                    .is_some_and(|p| p == parent);
2283            dir_match
2284                && (e.json_path.as_ref().is_some_and(|p| p.exists())
2285                    || e.html_path.as_ref().is_some_and(|p| p.exists()))
2286        });
2287        if already {
2288            continue;
2289        }
2290        let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2291            rd.flatten()
2292                .map(|e| e.path())
2293                .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2294        });
2295        let Ok(run) = read_json(&json_path) else {
2296            continue;
2297        };
2298        let project_label = run.input_roots.first().map_or_else(
2299            || "Unknown Project".to_string(),
2300            |r| sanitize_project_label(r),
2301        );
2302        let entry = RegistryEntry {
2303            run_id: run.tool.run_id.clone(),
2304            timestamp_utc: run.tool.timestamp_utc,
2305            project_label,
2306            input_roots: run.input_roots.clone(),
2307            json_path: Some(json_path),
2308            html_path,
2309            pdf_path: None,
2310            csv_path: None,
2311            xlsx_path: None,
2312            summary: ScanSummarySnapshot {
2313                files_analyzed: run.summary_totals.files_analyzed,
2314                files_skipped: run.summary_totals.files_skipped,
2315                total_physical_lines: run.summary_totals.total_physical_lines,
2316                code_lines: run.summary_totals.code_lines,
2317                comment_lines: run.summary_totals.comment_lines,
2318                blank_lines: run.summary_totals.blank_lines,
2319                functions: run.summary_totals.functions,
2320                classes: run.summary_totals.classes,
2321                variables: run.summary_totals.variables,
2322                imports: run.summary_totals.imports,
2323                test_count: run.summary_totals.test_count,
2324            },
2325            git_branch: run.git_branch.clone(),
2326            git_commit: run.git_commit_short.clone(),
2327            git_author: run.git_commit_author.clone(),
2328            git_tags: run.git_tags.clone(),
2329            git_nearest_tag: run.git_nearest_tag.clone(),
2330            git_commit_date: run.git_commit_date.clone(),
2331        };
2332        reg.add_entry(entry);
2333        linked += 1;
2334    }
2335    linked
2336}
2337
2338/// Scan all watched directories (plus the default output root) into `reg`.
2339async fn auto_scan_watched_dirs(state: &AppState) {
2340    let dirs: Vec<PathBuf> = {
2341        let wd = state.watched_dirs.lock().await;
2342        wd.dirs.clone()
2343    };
2344    if dirs.is_empty() {
2345        return;
2346    }
2347    let mut reg = state.registry.lock().await;
2348    let mut total = 0usize;
2349    for dir in &dirs {
2350        if dir.is_dir() {
2351            total += scan_folder_into_registry(dir, &mut reg);
2352        }
2353    }
2354    if total > 0 {
2355        let _ = reg.save(&state.registry_path);
2356    }
2357}
2358
2359// ── Watched-dir route forms ───────────────────────────────────────────────────
2360
2361#[derive(Deserialize)]
2362struct WatchedDirForm {
2363    folder_path: String,
2364    #[serde(default = "default_redirect")]
2365    redirect_to: String,
2366}
2367
2368fn default_redirect() -> String {
2369    "/view-reports".to_string()
2370}
2371
2372#[derive(Deserialize)]
2373struct WatchedDirRefreshForm {
2374    #[serde(default = "default_redirect")]
2375    redirect_to: String,
2376}
2377
2378// ── Watched-dir helpers ───────────────────────────────────────────────────────
2379
2380/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
2381fn safe_redirect(dest: &str) -> &str {
2382    if dest.starts_with('/') {
2383        dest
2384    } else {
2385        "/"
2386    }
2387}
2388
2389// ── Watched-dir handlers ──────────────────────────────────────────────────────
2390
2391async fn add_watched_dir_handler(
2392    State(state): State<AppState>,
2393    Form(form): Form<WatchedDirForm>,
2394) -> impl IntoResponse {
2395    if state.server_mode {
2396        return StatusCode::NOT_FOUND.into_response();
2397    }
2398    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2399        strip_unc_prefix(p)
2400    } else {
2401        let dest = format!(
2402            "{}?error=Folder+not+found+or+path+is+invalid.",
2403            safe_redirect(&form.redirect_to)
2404        );
2405        return axum::response::Redirect::to(&dest).into_response();
2406    };
2407    if !folder.is_dir() {
2408        let dest = format!(
2409            "{}?error=Selected+path+is+not+a+directory.",
2410            safe_redirect(&form.redirect_to)
2411        );
2412        return axum::response::Redirect::to(&dest).into_response();
2413    }
2414
2415    // Persist the watched directory.
2416    {
2417        let mut wd = state.watched_dirs.lock().await;
2418        wd.add(folder.clone());
2419        let _ = wd.save(&state.watched_dirs_path);
2420    }
2421
2422    // Immediately scan the folder and add any new reports.
2423    let linked = {
2424        let mut reg = state.registry.lock().await;
2425        let n = scan_folder_into_registry(&folder, &mut reg);
2426        if n > 0 {
2427            let _ = reg.save(&state.registry_path);
2428        }
2429        n
2430    };
2431
2432    let dest = if linked > 0 {
2433        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2434    } else {
2435        format!(
2436            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2437            safe_redirect(&form.redirect_to)
2438        )
2439    };
2440    axum::response::Redirect::to(&dest).into_response()
2441}
2442
2443async fn remove_watched_dir_handler(
2444    State(state): State<AppState>,
2445    Form(form): Form<WatchedDirForm>,
2446) -> impl IntoResponse {
2447    if state.server_mode {
2448        return StatusCode::NOT_FOUND.into_response();
2449    }
2450    let folder = PathBuf::from(&form.folder_path);
2451    {
2452        let mut wd = state.watched_dirs.lock().await;
2453        wd.remove(&folder);
2454        let _ = wd.save(&state.watched_dirs_path);
2455    }
2456    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2457}
2458
2459async fn refresh_watched_dirs_handler(
2460    State(state): State<AppState>,
2461    Form(form): Form<WatchedDirRefreshForm>,
2462) -> impl IntoResponse {
2463    if state.server_mode {
2464        return StatusCode::NOT_FOUND.into_response();
2465    }
2466    let dirs: Vec<PathBuf> = {
2467        let wd = state.watched_dirs.lock().await;
2468        wd.dirs.clone()
2469    };
2470    let mut total = 0usize;
2471    {
2472        let mut reg = state.registry.lock().await;
2473        for dir in &dirs {
2474            if dir.is_dir() {
2475                total += scan_folder_into_registry(dir, &mut reg);
2476            }
2477        }
2478        if total > 0 {
2479            let _ = reg.save(&state.registry_path);
2480        }
2481    }
2482    let dest = if total > 0 {
2483        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2484    } else {
2485        safe_redirect(&form.redirect_to).to_owned()
2486    };
2487    axum::response::Redirect::to(&dest).into_response()
2488}
2489
2490#[derive(Debug, Deserialize)]
2491struct OpenPathQuery {
2492    path: Option<String>,
2493}
2494
2495async fn open_path_handler(
2496    State(state): State<AppState>,
2497    Query(query): Query<OpenPathQuery>,
2498) -> impl IntoResponse {
2499    if state.server_mode {
2500        return StatusCode::NOT_FOUND.into_response();
2501    }
2502    let raw = match query.path.as_deref() {
2503        Some(p) if !p.is_empty() => p,
2504        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2505    };
2506
2507    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
2508    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
2509    // so the file explorer still opens somewhere useful.
2510    let target = match fs::canonicalize(raw) {
2511        Ok(canonical) if canonical.is_file() => match canonical.parent() {
2512            Some(p) => p.to_path_buf(),
2513            None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2514        },
2515        Ok(canonical) if canonical.is_dir() => canonical,
2516        Ok(_) => {
2517            return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2518        }
2519        Err(_) => {
2520            // Path doesn't exist — find nearest existing ancestor directory.
2521            let mut ancestor = std::path::Path::new(raw);
2522            loop {
2523                match ancestor.parent() {
2524                    Some(p) => {
2525                        ancestor = p;
2526                        if ancestor.is_dir() {
2527                            break;
2528                        }
2529                    }
2530                    None => {
2531                        return (StatusCode::BAD_REQUEST, "no existing ancestor found")
2532                            .into_response();
2533                    }
2534                }
2535            }
2536            ancestor.to_path_buf()
2537        }
2538    };
2539
2540    #[cfg(target_os = "windows")]
2541    {
2542        // Open the folder in Explorer, then use SetForegroundWindow + ShowWindow(SW_MAXIMIZE=3)
2543        // to ensure the window surfaces on top of all other windows.  The path is passed via
2544        // an environment variable to avoid any command-injection or escaping issues.
2545        let ps_cmd = "Add-Type -TypeDefinition \
2546            'using System;using System.Runtime.InteropServices;\
2547            public class WF{\
2548              [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
2549              [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
2550            }'; \
2551            $p=$env:SLOC_OPEN_PATH; \
2552            $sh=New-Object -ComObject Shell.Application; \
2553            $sh.Open($p); \
2554            Start-Sleep -Milliseconds 600; \
2555            foreach($w in $sh.Windows()){ \
2556              try{ \
2557                if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
2558                   [System.IO.Path]::GetFullPath($p)){ \
2559                  [WF]::ShowWindow($w.HWND,3); \
2560                  [WF]::SetForegroundWindow($w.HWND); \
2561                  break \
2562                } \
2563              }catch{} \
2564            }";
2565        let _ = std::process::Command::new("powershell")
2566            .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
2567            .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
2568            .stdout(Stdio::null())
2569            .stderr(Stdio::null())
2570            .spawn();
2571    }
2572    #[cfg(target_os = "macos")]
2573    let _ = std::process::Command::new("open")
2574        .arg(&target)
2575        .stdout(Stdio::null())
2576        .stderr(Stdio::null())
2577        .spawn();
2578    #[cfg(target_os = "linux")]
2579    let _ = std::process::Command::new("xdg-open")
2580        .arg(&target)
2581        .stdout(Stdio::null())
2582        .stderr(Stdio::null())
2583        .spawn();
2584
2585    (StatusCode::OK, "ok").into_response()
2586}
2587
2588async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
2589    let (content_type, bytes): (&'static str, &'static [u8]) =
2590        match (folder.as_str(), file.as_str()) {
2591            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
2592            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
2593            ("icons", "c.png") => ("image/png", IMG_ICON_C),
2594            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
2595            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
2596            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
2597            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
2598            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
2599            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
2600            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
2601            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
2602            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
2603            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
2604            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
2605            ("icons", "r.png") => ("image/png", IMG_ICON_R),
2606            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
2607            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
2608            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
2609            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
2610            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
2611            _ => return StatusCode::NOT_FOUND.into_response(),
2612        };
2613    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
2614}
2615
2616async fn preview_handler(
2617    State(state): State<AppState>,
2618    Query(query): Query<PreviewQuery>,
2619) -> impl IntoResponse {
2620    let raw_path = query
2621        .path
2622        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
2623    let resolved = resolve_input_path(&raw_path);
2624
2625    if state.server_mode {
2626        let config = &state.base_config;
2627        if config.discovery.allowed_scan_roots.is_empty() {
2628            return Html(
2629                r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
2630            );
2631        }
2632        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
2633        let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2634            fs::canonicalize(root)
2635                .ok()
2636                .is_some_and(|r| canonical.starts_with(&r))
2637        });
2638        if !allowed {
2639            return Html(
2640                r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
2641            );
2642        }
2643    }
2644
2645    let include_patterns = split_patterns(query.include_globs.as_deref());
2646    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
2647
2648    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
2649        Ok(html) => Html(html),
2650        Err(err) => Html(format!(
2651            r#"<div class="preview-error">Preview failed: {}</div>"#,
2652            escape_html(&err.to_string())
2653        )),
2654    }
2655}
2656
2657#[derive(Debug, Deserialize, Default)]
2658struct SuggestCoverageQuery {
2659    path: Option<String>,
2660}
2661
2662async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
2663    const CANDIDATES: &[&str] = &[
2664        // LCOV — cargo-llvm-cov, gcov, lcov
2665        "coverage/lcov.info",
2666        "lcov.info",
2667        "target/llvm-cov/lcov.info",
2668        "target/coverage/lcov.info",
2669        "target/debug/coverage/lcov.info",
2670        "coverage/coverage.lcov",
2671        "build/coverage/lcov.info",
2672        "reports/lcov.info",
2673        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
2674        "coverage.xml",
2675        "coverage/coverage.xml",
2676        "target/site/cobertura/coverage.xml",
2677        "build/reports/coverage/coverage.xml",
2678        // JaCoCo XML — Gradle, Maven JaCoCo plugin
2679        "target/site/jacoco/jacoco.xml",
2680        "build/reports/jacoco/test/jacocoTestReport.xml",
2681        "build/reports/jacoco/jacocoTestReport.xml",
2682        "build/jacoco/jacoco.xml",
2683    ];
2684    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
2685    let found = CANDIDATES
2686        .iter()
2687        .map(|rel| root.join(rel))
2688        .find(|p| p.is_file())
2689        .map(|p| display_path(&p));
2690
2691    let (tool, hint) = detect_coverage_tool(&root);
2692    Json(serde_json::json!({ "found": found, "tool": tool, "hint": hint }))
2693}
2694
2695/// Inspect the project root for known build/package files and return the most likely coverage
2696/// tool name and the shell command needed to generate a coverage file.
2697fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
2698    if root.join("Cargo.toml").is_file() {
2699        return (
2700            Some("cargo-llvm-cov"),
2701            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
2702        );
2703    }
2704    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
2705        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
2706    }
2707    if root.join("pom.xml").is_file() {
2708        return (Some("jacoco"), Some("mvn test jacoco:report"));
2709    }
2710    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
2711        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
2712    }
2713    (None, None)
2714}
2715
2716/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
2717#[allow(clippy::result_large_err)]
2718fn validate_server_scan_path(
2719    config: &sloc_config::AppConfig,
2720    resolved_path: &Path,
2721    csp_nonce: &str,
2722) -> Result<(), Response> {
2723    if config.discovery.allowed_scan_roots.is_empty() {
2724        let template = ErrorTemplate {
2725            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
2726                      Set allowed_scan_roots in the server config to permit scanning."
2727                .to_string(),
2728            last_report_url: None,
2729            last_report_label: None,
2730            csp_nonce: csp_nonce.to_owned(),
2731        };
2732        return Err((
2733            StatusCode::FORBIDDEN,
2734            Html(
2735                template
2736                    .render()
2737                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
2738            ),
2739        )
2740            .into_response());
2741    }
2742    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
2743    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2744        fs::canonicalize(root)
2745            .ok()
2746            .is_some_and(|r| canonical.starts_with(&r))
2747    });
2748    if !allowed {
2749        tracing::warn!(event = "path_rejected", path = %canonical.display(),
2750            "Scan path not in allowed_scan_roots");
2751        let template = ErrorTemplate {
2752            message: "The requested path is not within an allowed scan directory.".to_string(),
2753            last_report_url: None,
2754            last_report_label: None,
2755            csp_nonce: csp_nonce.to_owned(),
2756        };
2757        return Err((
2758            StatusCode::FORBIDDEN,
2759            Html(
2760                template
2761                    .render()
2762                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
2763            ),
2764        )
2765            .into_response());
2766    }
2767    Ok(())
2768}
2769
2770/// Exclude the output directory from scanning so artifacts don't pollute counts.
2771fn apply_output_dir_exclusions(
2772    config: &mut sloc_config::AppConfig,
2773    project_path: &str,
2774    raw_output_dir: &str,
2775) {
2776    let project_root = resolve_input_path(project_path);
2777    let raw_out = raw_output_dir.trim();
2778    let resolved_out = if raw_out.is_empty() {
2779        project_root.join("sloc")
2780    } else if Path::new(raw_out).is_absolute() {
2781        PathBuf::from(raw_out)
2782    } else {
2783        workspace_root().join(raw_out)
2784    };
2785    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
2786        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
2787            let dir = first.to_string();
2788            if !config.discovery.excluded_directories.contains(&dir) {
2789                config.discovery.excluded_directories.push(dir);
2790            }
2791        }
2792    }
2793    if !config
2794        .discovery
2795        .excluded_directories
2796        .iter()
2797        .any(|d| d == "sloc")
2798    {
2799        config
2800            .discovery
2801            .excluded_directories
2802            .push("sloc".to_string());
2803    }
2804}
2805
2806/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
2807const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
2808    ScanSummarySnapshot {
2809        files_analyzed: run.summary_totals.files_analyzed,
2810        files_skipped: run.summary_totals.files_skipped,
2811        total_physical_lines: run.summary_totals.total_physical_lines,
2812        code_lines: run.summary_totals.code_lines,
2813        comment_lines: run.summary_totals.comment_lines,
2814        blank_lines: run.summary_totals.blank_lines,
2815        functions: run.summary_totals.functions,
2816        classes: run.summary_totals.classes,
2817        variables: run.summary_totals.variables,
2818        imports: run.summary_totals.imports,
2819        test_count: run.summary_totals.test_count,
2820    }
2821}
2822
2823/// Build the `RegistryEntry` for the just-completed scan run.
2824pub(crate) fn build_run_registry_entry(
2825    run: &AnalysisRun,
2826    run_id: &str,
2827    project_label: &str,
2828    artifacts: &RunArtifacts,
2829) -> RegistryEntry {
2830    RegistryEntry {
2831        run_id: run_id.to_owned(),
2832        timestamp_utc: run.tool.timestamp_utc,
2833        project_label: project_label.to_owned(),
2834        input_roots: run.input_roots.clone(),
2835        json_path: artifacts.json_path.clone(),
2836        html_path: artifacts.html_path.clone(),
2837        pdf_path: artifacts.pdf_path.clone(),
2838        csv_path: artifacts.csv_path.clone(),
2839        xlsx_path: artifacts.xlsx_path.clone(),
2840        summary: summary_snapshot_from_run(run),
2841        git_branch: run.git_branch.clone(),
2842        git_commit: run.git_commit_short.clone(),
2843        git_author: run.git_commit_author.clone(),
2844        git_tags: run.git_tags.clone(),
2845        git_nearest_tag: run.git_nearest_tag.clone(),
2846        git_commit_date: run.git_commit_date.clone(),
2847    }
2848}
2849
2850/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
2851fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
2852    if let Some(policy) = form.mixed_line_policy {
2853        config.analysis.mixed_line_policy = policy;
2854    }
2855    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
2856    config.analysis.generated_file_detection =
2857        form.generated_file_detection.as_deref() != Some("disabled");
2858    config.analysis.minified_file_detection =
2859        form.minified_file_detection.as_deref() != Some("disabled");
2860    config.analysis.vendor_directory_detection =
2861        form.vendor_directory_detection.as_deref() != Some("disabled");
2862    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
2863    if let Some(binary_behavior) = form.binary_file_behavior {
2864        config.analysis.binary_file_behavior = binary_behavior;
2865    }
2866    if let Some(report_title) = form.report_title.as_deref() {
2867        let trimmed = report_title.trim();
2868        if !trimmed.is_empty() {
2869            config.reporting.report_title = trimmed.to_string();
2870        }
2871    }
2872    if let Some(hf) = form.report_header_footer.as_deref() {
2873        let trimmed = hf.trim();
2874        config.reporting.report_header_footer = if trimmed.is_empty() {
2875            None
2876        } else {
2877            Some(trimmed.to_string())
2878        };
2879    }
2880    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
2881    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
2882    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
2883    if let Some(cov) = &form.coverage_file {
2884        let trimmed = cov.trim();
2885        if !trimmed.is_empty() {
2886            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
2887        }
2888    }
2889}
2890
2891/// Fire-and-forget: generate the PDF in a background task if one is pending.
2892/// On failure, clears `pdf_path` in the artifacts map so the results page shows
2893/// an error instead of spinning indefinitely.
2894fn spawn_pdf_background(
2895    pending_pdf: PendingPdf,
2896    run_id: String,
2897    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
2898) {
2899    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
2900        tokio::spawn(async move {
2901            let result = tokio::task::spawn_blocking(move || {
2902                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
2903                if cleanup_src {
2904                    let _ = fs::remove_file(&pdf_src);
2905                }
2906                r
2907            })
2908            .await;
2909            let failed = match result {
2910                Ok(Ok(())) => false,
2911                Ok(Err(err)) => {
2912                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
2913                    true
2914                }
2915                Err(err) => {
2916                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
2917                    true
2918                }
2919            };
2920            if failed {
2921                let mut map = artifacts.lock().await;
2922                if let Some(entry) = map.get_mut(&run_id) {
2923                    entry.pdf_path = None;
2924                }
2925            }
2926        });
2927    }
2928}
2929
2930/// Sum the code lines added in this comparison (new + grown files).
2931fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2932    cmp.file_deltas
2933        .iter()
2934        .map(|f| match f.status {
2935            FileChangeStatus::Added => f.current_code,
2936            FileChangeStatus::Modified => f.code_delta.max(0),
2937            _ => 0,
2938        })
2939        .sum()
2940}
2941
2942/// Sum the code lines removed in this comparison (deleted + shrunk files).
2943fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2944    cmp.file_deltas
2945        .iter()
2946        .map(|f| match f.status {
2947            FileChangeStatus::Removed => f.baseline_code,
2948            FileChangeStatus::Modified => (-f.code_delta).max(0),
2949            _ => 0,
2950        })
2951        .sum()
2952}
2953
2954/// Build one `SubmoduleRow`, optionally generating and persisting a sub-report HTML file.
2955fn build_submodule_row(
2956    s: &sloc_core::SubmoduleSummary,
2957    run: &AnalysisRun,
2958    run_id: &str,
2959    run_dir: &Path,
2960    generate_html: bool,
2961) -> SubmoduleRow {
2962    let safe = sanitize_project_label(&s.name);
2963    let artifact_key = format!("sub_{safe}");
2964    let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
2965        let parent_path = run
2966            .input_roots
2967            .first()
2968            .map_or("", std::string::String::as_str);
2969        let sub_run = build_sub_run(run, s, parent_path);
2970        render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
2971            let path = run_dir.join(format!("{artifact_key}.html"));
2972            if fs::write(&path, sub_html.as_bytes()).is_ok() {
2973                Some(format!("/runs/{artifact_key}/{run_id}"))
2974            } else {
2975                None
2976            }
2977        })
2978    } else {
2979        None
2980    };
2981    SubmoduleRow {
2982        name: s.name.clone(),
2983        relative_path: s.relative_path.clone(),
2984        files_analyzed: s.files_analyzed,
2985        code_lines: s.code_lines,
2986        comment_lines: s.comment_lines,
2987        blank_lines: s.blank_lines,
2988        total_physical_lines: s.total_physical_lines,
2989        html_url,
2990    }
2991}
2992
2993// Immediately returns a wait page and runs the analysis in a background tokio task.
2994// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
2995#[allow(clippy::too_many_lines)]
2996#[allow(clippy::similar_names)]
2997async fn analyze_handler(
2998    // NOSONAR(rust:S3776)
2999    State(state): State<AppState>,
3000    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3001    Form(form): Form<AnalyzeForm>,
3002) -> impl IntoResponse {
3003    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
3004        let template = ErrorTemplate {
3005            message: "Server is busy — too many concurrent analyses. Please try again in a moment."
3006                .to_string(),
3007            last_report_url: None,
3008            last_report_label: None,
3009            csp_nonce: csp_nonce.clone(),
3010        };
3011        return (
3012            StatusCode::SERVICE_UNAVAILABLE,
3013            Html(
3014                template
3015                    .render()
3016                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
3017            ),
3018        )
3019            .into_response();
3020    };
3021
3022    let mut config = state.base_config.clone();
3023
3024    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
3025    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
3026    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
3027
3028    if !is_git_mode {
3029        let resolved_path = resolve_input_path(&form.path);
3030        if state.server_mode {
3031            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
3032                return resp;
3033            }
3034        }
3035        config.discovery.root_paths = vec![resolved_path];
3036    }
3037
3038    apply_form_to_config(&mut config, &form);
3039    apply_output_dir_exclusions(
3040        &mut config,
3041        &form.path,
3042        form.output_dir.as_deref().unwrap_or(""),
3043    );
3044
3045    // Generate a wait_id now (before spawning) so the client can poll for status.
3046    let wait_id = uuid::Uuid::new_v4().to_string();
3047    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
3048
3049    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
3050    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
3051
3052    // Clone everything the background task needs before moving into the spawn.
3053    let project_path_bg = form.path.clone();
3054    let output_dir_bg = form.output_dir.clone();
3055    let git_repo_bg = form.git_repo.clone().filter(|s| !s.is_empty());
3056    let git_ref_bg = form.git_ref.clone().filter(|s| !s.is_empty());
3057    let generate_html_bg = form.generate_html.is_some();
3058    let generate_pdf_bg = form.generate_pdf.is_some();
3059    let clones_dir = state.git_clones_dir.clone();
3060    let wait_id_bg = wait_id.clone();
3061    let state_bg = state.clone();
3062    let cancel_bg = Arc::clone(&cancel_token);
3063
3064    {
3065        let mut runs = state.async_runs.lock().await;
3066        runs.insert(
3067            wait_id.clone(),
3068            AsyncRunState::Running {
3069                started_at: std::time::Instant::now(),
3070                cancel_token,
3071            },
3072        );
3073    }
3074
3075    tokio::spawn(async move {
3076        // Hold the permit for the lifetime of the background task.
3077        let _permit = sem_permit;
3078
3079        // Clone before moving into spawn_blocking so we can use them again afterwards.
3080        let git_repo_sb = git_repo_bg.clone();
3081        let git_ref_sb = git_ref_bg.clone();
3082        let cancel_sb = Arc::clone(&cancel_bg);
3083        let analysis_result =
3084            tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
3085                if let (Some(repo), Some(refname)) = (&git_repo_sb, &git_ref_sb) {
3086                    let dest = git_clone_dest(repo, &clones_dir);
3087                    sloc_git::clone_or_fetch(repo, &dest)?;
3088                    let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3089                    sloc_git::create_worktree(&dest, refname, &wt)?;
3090                    config.discovery.root_paths = vec![wt.clone()];
3091                    let run = analyze(&config, "serve", Some(&cancel_sb));
3092                    let _ = sloc_git::destroy_worktree(&dest, &wt);
3093                    let mut run = run?;
3094                    if run.git_branch.is_none() {
3095                        run.git_branch = Some(refname.clone());
3096                    }
3097                    let html = render_html(&run)?;
3098                    return Ok((run, html));
3099                }
3100                let run = analyze(&config, "serve", Some(&cancel_sb))?;
3101                let html = render_html(&run)?;
3102                Ok((run, html))
3103            })
3104            .await
3105            .map_err(|err| anyhow::anyhow!(err.to_string()))
3106            .and_then(|result| result);
3107
3108        // If cancelled while running, discard results and mark as cancelled.
3109        if cancel_bg.load(std::sync::atomic::Ordering::Relaxed) {
3110            let mut runs = state_bg.async_runs.lock().await;
3111            // Only overwrite if still Running (don't clobber a Complete that snuck in).
3112            if matches!(
3113                runs.get(&wait_id_bg),
3114                Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
3115            ) {
3116                runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
3117            }
3118            drop(runs);
3119            return;
3120        }
3121
3122        let (run, report_html) = match analysis_result {
3123            Ok(v) => v,
3124            Err(err) => {
3125                // Distinguish user-cancelled from real failure.
3126                let message = if err.to_string().contains("analysis cancelled") {
3127                    let mut runs = state_bg.async_runs.lock().await;
3128                    runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
3129                    drop(runs);
3130                    return;
3131                } else {
3132                    "Analysis failed. Check that the path exists and is readable.".to_string()
3133                };
3134                eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
3135                let mut runs = state_bg.async_runs.lock().await;
3136                runs.insert(wait_id_bg.clone(), AsyncRunState::Failed { message });
3137                drop(runs);
3138                return;
3139            }
3140        };
3141
3142        let run_id = run.tool.run_id.clone();
3143        tracing::info!(event = "scan_complete", run_id = %run_id,
3144            path = %project_path_bg, files = run.summary_totals.files_analyzed,
3145            "Analysis finished");
3146
3147        let prev_entry: Option<RegistryEntry> = {
3148            let reg = state_bg.registry.lock().await;
3149            reg.entries_for_roots(&run.input_roots)
3150                .into_iter()
3151                .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3152                .cloned()
3153        };
3154
3155        let scan_delta = prev_entry.as_ref().and_then(|prev| {
3156            prev.json_path
3157                .as_ref()
3158                .and_then(|p| read_json(p).ok())
3159                .map(|prev_run| compute_delta(&prev_run, &run))
3160        });
3161        let prev_scan_count: usize = {
3162            let reg = state_bg.registry.lock().await;
3163            reg.entries_for_roots(&run.input_roots)
3164                .iter()
3165                .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3166                .count()
3167        };
3168
3169        let output_root = resolve_output_root(output_dir_bg.as_deref());
3170
3171        let project_label = if let (Some(repo), Some(refname)) = (
3172            git_repo_bg.as_deref().filter(|s| !s.is_empty()),
3173            git_ref_bg.as_deref().filter(|s| !s.is_empty()),
3174        ) {
3175            let repo_name = repo
3176                .trim_end_matches('/')
3177                .trim_end_matches(".git")
3178                .rsplit('/')
3179                .next()
3180                .unwrap_or("repo");
3181            sanitize_project_label(&format!("{repo_name}_{refname}"))
3182        } else {
3183            sanitize_project_label(&project_path_bg)
3184        };
3185        let run_dir = output_root.join(format!("{project_label}_{run_id}"));
3186        let file_stem = {
3187            let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
3188            if commit.is_empty() {
3189                project_label.clone()
3190            } else {
3191                format!("{project_label}_{commit}")
3192            }
3193        };
3194
3195        let result_context = RunResultContext {
3196            prev_entry: prev_entry.clone(),
3197            prev_scan_count,
3198            project_path: project_path_bg.clone(),
3199        };
3200
3201        let artifact_result = persist_run_artifacts(
3202            &run,
3203            &report_html,
3204            &run_dir,
3205            true,
3206            generate_html_bg,
3207            generate_pdf_bg,
3208            &run.effective_configuration.reporting.report_title,
3209            &file_stem,
3210            result_context,
3211        );
3212
3213        let (artifacts, pending_pdf) = match artifact_result {
3214            Ok(v) => v,
3215            Err(err) => {
3216                eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
3217                let mut runs = state_bg.async_runs.lock().await;
3218                runs.insert(
3219                    wait_id_bg.clone(),
3220                    AsyncRunState::Failed {
3221                        message: "Failed to save report artifacts. Check available disk space."
3222                            .to_string(),
3223                    },
3224                );
3225                drop(runs);
3226                return;
3227            }
3228        };
3229
3230        {
3231            let mut map = state_bg.artifacts.lock().await;
3232            map.insert(run_id.clone(), artifacts.clone());
3233        }
3234
3235        {
3236            let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
3237            let mut reg = state_bg.registry.lock().await;
3238            reg.add_entry(entry);
3239            let _ = reg.save(&state_bg.registry_path);
3240        }
3241
3242        if let Some(ref cfg_path) = artifacts.scan_config_path {
3243            let policy_str =
3244                serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
3245                    .ok()
3246                    .and_then(|v| v.as_str().map(String::from))
3247                    .unwrap_or_else(|| "code_only".to_string());
3248            let behavior_str =
3249                serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
3250                    .ok()
3251                    .and_then(|v| v.as_str().map(String::from))
3252                    .unwrap_or_else(|| "skip".to_string());
3253            let scan_cfg = ScanConfig {
3254                oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
3255                path: project_path_bg.clone(),
3256                include_globs: run
3257                    .effective_configuration
3258                    .discovery
3259                    .include_globs
3260                    .join("\n"),
3261                exclude_globs: run
3262                    .effective_configuration
3263                    .discovery
3264                    .exclude_globs
3265                    .join("\n"),
3266                submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
3267                mixed_line_policy: policy_str,
3268                python_docstrings_as_comments: run
3269                    .effective_configuration
3270                    .analysis
3271                    .python_docstrings_as_comments,
3272                generated_file_detection: run
3273                    .effective_configuration
3274                    .analysis
3275                    .generated_file_detection,
3276                minified_file_detection: run
3277                    .effective_configuration
3278                    .analysis
3279                    .minified_file_detection,
3280                vendor_directory_detection: run
3281                    .effective_configuration
3282                    .analysis
3283                    .vendor_directory_detection,
3284                include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
3285                binary_file_behavior: behavior_str,
3286                output_dir: output_dir_bg.clone().unwrap_or_default(),
3287                report_title: run.effective_configuration.reporting.report_title.clone(),
3288                generate_html: generate_html_bg,
3289                generate_pdf: generate_pdf_bg,
3290            };
3291            if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3292                let _ = std::fs::write(cfg_path, json);
3293            }
3294        }
3295
3296        spawn_pdf_background(pending_pdf, run_id.clone(), state_bg.artifacts.clone());
3297
3298        // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
3299        let mut runs = state_bg.async_runs.lock().await;
3300        runs.insert(
3301            wait_id_bg.clone(),
3302            AsyncRunState::Complete {
3303                run_id: run_id.clone(),
3304            },
3305        );
3306        drop(runs);
3307
3308        // Submodule sub-reports are rendered synchronously above inside background task.
3309        let _ = scan_delta;
3310    });
3311
3312    let template = ScanWaitTemplate {
3313        version: env!("CARGO_PKG_VERSION"),
3314        wait_id_json,
3315        project_path: form.path.clone(),
3316        csp_nonce,
3317    };
3318    let html = template
3319        .render()
3320        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
3321    let mut response = Html(html).into_response();
3322    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
3323        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
3324            response.headers_mut().insert(name, val);
3325        }
3326    }
3327    response
3328}
3329
3330// ── Async scan status + result handlers ──────────────────────────────────────
3331
3332#[derive(Serialize)]
3333#[serde(tag = "state", rename_all = "snake_case")]
3334enum AsyncRunStatusResponse {
3335    Running { elapsed_secs: u64 },
3336    Complete { run_id: String },
3337    Failed { message: String },
3338    Cancelled,
3339}
3340
3341async fn async_run_status_handler(
3342    State(state): State<AppState>,
3343    AxumPath(wait_id): AxumPath<String>,
3344) -> Response {
3345    // wait_id comes from our own UUID generator; reject any structurally malformed value.
3346    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3347        return StatusCode::BAD_REQUEST.into_response();
3348    }
3349    let run_state = {
3350        let runs = state.async_runs.lock().await;
3351        runs.get(&wait_id).cloned()
3352    };
3353    match run_state {
3354        None => StatusCode::NOT_FOUND.into_response(),
3355        Some(AsyncRunState::Running { started_at, .. }) => {
3356            // Treat runs older than 2 h as timed out (analysis should finish well under that).
3357            if started_at.elapsed() > std::time::Duration::from_hours(2) {
3358                let mut runs = state.async_runs.lock().await;
3359                runs.insert(
3360                    wait_id,
3361                    AsyncRunState::Failed {
3362                        message: "Analysis timed out after 2 hours.".to_string(),
3363                    },
3364                );
3365                drop(runs);
3366                return Json(AsyncRunStatusResponse::Failed {
3367                    message: "Analysis timed out after 2 hours.".to_string(),
3368                })
3369                .into_response();
3370            }
3371            Json(AsyncRunStatusResponse::Running {
3372                elapsed_secs: started_at.elapsed().as_secs(),
3373            })
3374            .into_response()
3375        }
3376        Some(AsyncRunState::Complete { run_id }) => {
3377            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
3378        }
3379        Some(AsyncRunState::Failed { message }) => {
3380            Json(AsyncRunStatusResponse::Failed { message }).into_response()
3381        }
3382        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
3383    }
3384}
3385
3386async fn cancel_run_handler(
3387    State(state): State<AppState>,
3388    AxumPath(wait_id): AxumPath<String>,
3389) -> Response {
3390    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3391        return StatusCode::BAD_REQUEST.into_response();
3392    }
3393    let mut runs = state.async_runs.lock().await;
3394    let resp = match runs.get(&wait_id) {
3395        Some(AsyncRunState::Running { cancel_token, .. }) => {
3396            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
3397            runs.insert(wait_id, AsyncRunState::Cancelled);
3398            StatusCode::OK.into_response()
3399        }
3400        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
3401        _ => StatusCode::NOT_FOUND.into_response(),
3402    };
3403    drop(runs);
3404    resp
3405}
3406
3407async fn async_run_result_handler(
3408    State(state): State<AppState>,
3409    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3410    AxumPath(run_id): AxumPath<String>,
3411) -> Response {
3412    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
3413        return StatusCode::BAD_REQUEST.into_response();
3414    }
3415
3416    let artifacts = {
3417        let map = state.artifacts.lock().await;
3418        map.get(&run_id).cloned()
3419    };
3420    let artifacts = if let Some(a) = artifacts {
3421        a
3422    } else {
3423        let reg = state.registry.lock().await;
3424        if let Some(entry) = reg.find_by_run_id(&run_id) {
3425            recover_artifacts_from_registry(entry)
3426        } else {
3427            let html = ErrorTemplate {
3428                message: format!(
3429                    "Report not found. Run ID {} is not in the scan history.",
3430                    &run_id[..run_id.len().min(8)]
3431                ),
3432                last_report_url: Some("/view-reports".to_string()),
3433                last_report_label: Some("View Reports".to_string()),
3434                csp_nonce: csp_nonce.clone(),
3435            }
3436            .render()
3437            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3438            return (StatusCode::NOT_FOUND, Html(html)).into_response();
3439        }
3440    };
3441
3442    let json_path = if let Some(p) = &artifacts.json_path {
3443        p.clone()
3444    } else {
3445        let html = ErrorTemplate {
3446            message: "JSON result was not saved for this run.".to_string(),
3447            last_report_url: Some("/view-reports".to_string()),
3448            last_report_label: Some("View Reports".to_string()),
3449            csp_nonce: csp_nonce.clone(),
3450        }
3451        .render()
3452        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
3453        return (StatusCode::NOT_FOUND, Html(html)).into_response();
3454    };
3455
3456    let Ok(run) = read_json(&json_path) else {
3457        let folder_hint = json_path
3458            .parent()
3459            .map(|p| p.display().to_string())
3460            .unwrap_or_default();
3461        let redirect_url = format!("/runs/result/{run_id}");
3462        return missing_scan_relocate_response(
3463            &format!(
3464                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
3465                 deleted. Browse to the folder containing your scan output to reconnect it.",
3466                json_path.display()
3467            ),
3468            &run_id,
3469            &folder_hint,
3470            &redirect_url,
3471            state.server_mode,
3472            &csp_nonce,
3473        );
3474    };
3475
3476    let confluence_configured = {
3477        let store = state.confluence.lock().await;
3478        store.is_configured()
3479    };
3480
3481    render_result_page(&run, &artifacts, &run_id, &csp_nonce, confluence_configured)
3482}
3483
3484#[allow(clippy::too_many_lines)]
3485#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
3486fn render_result_page(
3487    // NOSONAR(rust:S3776)
3488    run: &AnalysisRun,
3489    artifacts: &RunArtifacts,
3490    run_id: &str,
3491    csp_nonce: &str,
3492    confluence_configured: bool,
3493) -> Response {
3494    let ctx = &artifacts.result_context;
3495    let prev_entry = &ctx.prev_entry;
3496    let prev_scan_count = ctx.prev_scan_count;
3497    let project_path = &ctx.project_path;
3498
3499    let scan_delta = prev_entry.as_ref().and_then(|prev| {
3500        prev.json_path
3501            .as_ref()
3502            .and_then(|p| read_json(p).ok())
3503            .map(|prev_run| compute_delta(&prev_run, run))
3504    });
3505
3506    let files_analyzed = run.per_file_records.len() as u64;
3507    let files_skipped = run.skipped_file_records.len() as u64;
3508    let physical_lines = run
3509        .totals_by_language
3510        .iter()
3511        .map(|r| r.total_physical_lines)
3512        .sum::<u64>();
3513    let code_lines = run
3514        .totals_by_language
3515        .iter()
3516        .map(|r| r.code_lines)
3517        .sum::<u64>();
3518    let comment_lines = run
3519        .totals_by_language
3520        .iter()
3521        .map(|r| r.comment_lines)
3522        .sum::<u64>();
3523    let blank_lines = run
3524        .totals_by_language
3525        .iter()
3526        .map(|r| r.blank_lines)
3527        .sum::<u64>();
3528    let mixed_lines = run
3529        .totals_by_language
3530        .iter()
3531        .map(|r| r.mixed_lines_separate)
3532        .sum::<u64>();
3533    let functions = run
3534        .totals_by_language
3535        .iter()
3536        .map(|r| r.functions)
3537        .sum::<u64>();
3538    let classes = run
3539        .totals_by_language
3540        .iter()
3541        .map(|r| r.classes)
3542        .sum::<u64>();
3543    let variables = run
3544        .totals_by_language
3545        .iter()
3546        .map(|r| r.variables)
3547        .sum::<u64>();
3548    let imports = run
3549        .totals_by_language
3550        .iter()
3551        .map(|r| r.imports)
3552        .sum::<u64>();
3553
3554    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
3555    let prev_fa = prev_sum.map(|s| s.files_analyzed);
3556    let prev_fs = prev_sum.map(|s| s.files_skipped);
3557    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
3558    let prev_cl = prev_sum.map(|s| s.code_lines);
3559    let prev_cml = prev_sum.map(|s| s.comment_lines);
3560    let prev_bl = prev_sum.map(|s| s.blank_lines);
3561    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
3562    let prev_fa_str = fmt_prev(prev_fa);
3563    let prev_fs_str = fmt_prev(prev_fs);
3564    let prev_pl_str = fmt_prev(prev_pl);
3565    let prev_cl_str = fmt_prev(prev_cl);
3566    let prev_cml_str = fmt_prev(prev_cml);
3567    let prev_bl_str = fmt_prev(prev_bl);
3568    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
3569    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
3570    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
3571    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
3572    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
3573    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
3574    let delta_fa_class = delta_fa_class.to_string();
3575    let delta_fs_class = delta_fs_class.to_string();
3576    let delta_pl_class = delta_pl_class.to_string();
3577    let delta_cl_class = delta_cl_class.to_string();
3578    let delta_cml_class = delta_cml_class.to_string();
3579    let delta_bl_class = delta_bl_class.to_string();
3580
3581    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
3582    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
3583    let (delta_lines_net_str, delta_lines_net_class) =
3584        match (delta_lines_added, delta_lines_removed) {
3585            (Some(a), Some(r)) => {
3586                let net = a - r;
3587                (fmt_delta(net), delta_class(net).to_string())
3588            }
3589            _ => ("—".to_string(), "na".to_string()),
3590        };
3591
3592    let run_dir = artifacts.output_dir.clone();
3593    let git_branch = run.git_branch.clone();
3594    let git_commit = run.git_commit_short.clone();
3595    let git_author = run.git_commit_author.clone();
3596
3597    let template = ResultTemplate {
3598        version: env!("CARGO_PKG_VERSION"),
3599        report_title: run.effective_configuration.reporting.report_title.clone(),
3600        project_path: project_path.clone(),
3601        output_dir: display_path(&artifacts.output_dir),
3602        run_id: run_id.to_owned(),
3603        files_analyzed,
3604        files_skipped,
3605        physical_lines,
3606        code_lines,
3607        comment_lines,
3608        blank_lines,
3609        mixed_lines,
3610        functions,
3611        classes,
3612        variables,
3613        imports,
3614        html_url: artifacts
3615            .html_path
3616            .as_ref()
3617            .map(|_| format!("/runs/html/{run_id}")),
3618        pdf_url: artifacts
3619            .pdf_path
3620            .as_ref()
3621            .map(|_| format!("/runs/pdf/{run_id}")),
3622        json_url: artifacts
3623            .json_path
3624            .as_ref()
3625            .map(|_| format!("/runs/json/{run_id}")),
3626        html_download_url: artifacts
3627            .html_path
3628            .as_ref()
3629            .map(|_| format!("/runs/html/{run_id}?download=1")),
3630        pdf_download_url: artifacts
3631            .pdf_path
3632            .as_ref()
3633            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
3634        json_download_url: artifacts
3635            .json_path
3636            .as_ref()
3637            .map(|_| format!("/runs/json/{run_id}?download=1")),
3638        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
3639        pdf_path: artifacts.pdf_path.as_ref().map(|p| display_path(p)),
3640        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
3641        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
3642        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
3643        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
3644        prev_fa_str,
3645        prev_fs_str,
3646        prev_pl_str,
3647        prev_cl_str,
3648        prev_cml_str,
3649        prev_bl_str,
3650        delta_fa_str,
3651        delta_fa_class,
3652        delta_fs_str,
3653        delta_fs_class,
3654        delta_pl_str,
3655        delta_pl_class,
3656        delta_cl_str,
3657        delta_cl_class,
3658        delta_cml_str,
3659        delta_cml_class,
3660        delta_bl_str,
3661        delta_bl_class,
3662        delta_lines_added,
3663        delta_lines_removed,
3664        delta_lines_net_str,
3665        delta_lines_net_class,
3666        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
3667        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
3668        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
3669        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
3670        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
3671            d.file_deltas
3672                .iter()
3673                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
3674                .map(|f| {
3675                    #[allow(clippy::cast_sign_loss)]
3676                    let n = f.current_code as u64;
3677                    n
3678                })
3679                .sum()
3680        }),
3681        git_branch,
3682        git_commit,
3683        git_author,
3684        current_scan_number: prev_scan_count + 1,
3685        prev_scan_count,
3686        submodule_rows: run
3687            .submodule_summaries
3688            .iter()
3689            .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
3690            .collect(),
3691        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
3692        scan_config_url: format!("/runs/scan-config/{run_id}"),
3693        lang_chart_json: {
3694            let entries: Vec<String> = run
3695                .totals_by_language
3696                .iter()
3697                .take(12)
3698                .map(|l| {
3699                    let name = l
3700                        .language
3701                        .display_name()
3702                        .replace('\\', "\\\\")
3703                        .replace('"', "\\\"");
3704                    format!(
3705                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
3706                        name,
3707                        l.code_lines,
3708                        l.comment_lines,
3709                        l.blank_lines,
3710                        l.functions,
3711                        l.classes,
3712                        l.variables,
3713                        l.imports,
3714                        l.files,
3715                    )
3716                })
3717                .collect();
3718            format!("[{}]", entries.join(","))
3719        },
3720        scatter_chart_json: {
3721            let entries: Vec<String> = run
3722                .totals_by_language
3723                .iter()
3724                .map(|l| {
3725                    let name = l
3726                        .language
3727                        .display_name()
3728                        .replace('\\', "\\\\")
3729                        .replace('"', "\\\"");
3730                    format!(
3731                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
3732                        name, l.files, l.code_lines, l.total_physical_lines,
3733                    )
3734                })
3735                .collect();
3736            format!("[{}]", entries.join(","))
3737        },
3738        semantic_chart_json: {
3739            let entries: Vec<String> = run
3740                .totals_by_language
3741                .iter()
3742                .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
3743                .map(|l| {
3744                    let name = l
3745                        .language
3746                        .display_name()
3747                        .replace('\\', "\\\\")
3748                        .replace('"', "\\\"");
3749                    format!(
3750                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
3751                        name, l.functions, l.classes, l.variables, l.imports,
3752                    )
3753                })
3754                .collect();
3755            format!("[{}]", entries.join(","))
3756        },
3757        submodule_chart_json: {
3758            let entries: Vec<String> = run
3759                .submodule_summaries
3760                .iter()
3761                .map(|s| {
3762                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
3763                    format!(
3764                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
3765                        name,
3766                        s.code_lines,
3767                        s.comment_lines,
3768                        s.blank_lines,
3769                        s.total_physical_lines,
3770                        s.files_analyzed,
3771                    )
3772                })
3773                .collect();
3774            format!("[{}]", entries.join(","))
3775        },
3776        has_submodule_data: !run.submodule_summaries.is_empty(),
3777        has_semantic_data: run
3778            .totals_by_language
3779            .iter()
3780            .any(|l| l.functions > 0 || l.classes > 0),
3781        csp_nonce: csp_nonce.to_owned(),
3782        confluence_configured,
3783        report_header_footer: run
3784            .effective_configuration
3785            .reporting
3786            .report_header_footer
3787            .clone(),
3788    };
3789
3790    Html(
3791        template
3792            .render()
3793            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
3794    )
3795    .into_response()
3796}
3797
3798fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
3799    let slug: String = report_title
3800        .chars()
3801        .map(|c| {
3802            if c.is_alphanumeric() || c == '-' {
3803                c.to_ascii_lowercase()
3804            } else {
3805                '_'
3806            }
3807        })
3808        .collect::<String>()
3809        .split('_')
3810        .filter(|s| !s.is_empty())
3811        .collect::<Vec<_>>()
3812        .join("_");
3813
3814    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
3815
3816    if slug.is_empty() {
3817        format!("report_{short_id}.pdf")
3818    } else {
3819        format!("{slug}_{short_id}.pdf")
3820    }
3821}
3822
3823/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
3824/// Clients poll this to update the button state without page reloads.
3825async fn pdf_status_handler(
3826    State(state): State<AppState>,
3827    AxumPath(run_id): AxumPath<String>,
3828) -> Response {
3829    let pdf_path = {
3830        let registry = state.artifacts.lock().await;
3831        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
3832    };
3833    let pdf_path = if pdf_path.is_some() {
3834        pdf_path
3835    } else {
3836        let reg = state.registry.lock().await;
3837        reg.find_by_run_id(&run_id)
3838            .map(recover_artifacts_from_registry)
3839            .and_then(|a| a.pdf_path)
3840    };
3841    let ready = pdf_path.is_some_and(|p| p.exists());
3842    Json(serde_json::json!({"ready": ready})).into_response()
3843}
3844
3845/// Serve the HTML artifact for a run — view or download.
3846/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
3847/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
3848/// current-request Content-Security-Policy nonce check.
3849fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
3850    // Find the first nonce value that was baked in at render time.
3851    let Some(start) = html.find("nonce=\"") else {
3852        // Reports generated before nonce support was added have bare <style> and <script>
3853        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
3854        // the inline blocks — without it the browser blocks all CSS and JS.
3855        return html
3856            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
3857            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
3858    };
3859    let value_start = start + 7; // len(r#"nonce=""#) == 7
3860    let Some(end_offset) = html[value_start..].find('"') else {
3861        return html.to_owned();
3862    };
3863    let old_nonce = &html[value_start..value_start + end_offset];
3864    html.replace(
3865        &format!("nonce=\"{old_nonce}\""),
3866        &format!("nonce=\"{new_nonce}\""),
3867    )
3868}
3869
3870fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3871    match fs::read_to_string(path) {
3872        Ok(raw) => {
3873            // Patch the saved nonce so inline styles/scripts pass CSP.
3874            let content = patch_html_nonce(&raw, csp_nonce);
3875            if wants_download {
3876                (
3877                    [
3878                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
3879                        (
3880                            header::CONTENT_DISPOSITION,
3881                            "attachment; filename=report.html",
3882                        ),
3883                    ],
3884                    content,
3885                )
3886                    .into_response()
3887            } else {
3888                Html(content).into_response()
3889            }
3890        }
3891        Err(err) => {
3892            let filename = path.file_name().map_or_else(
3893                || "report.html".to_string(),
3894                |n| n.to_string_lossy().into_owned(),
3895            );
3896            let msg = format!(
3897                "HTML report '{filename}' could not be read.\n\n\
3898                 Error: {err}\n\n\
3899                 If you moved or renamed the output folder, the stored path is now stale. \
3900                 Use 'Open HTML folder' from the results page to browse the output directory."
3901            );
3902            let html = ErrorTemplate {
3903                message: msg,
3904                last_report_url: Some("/view-reports".to_string()),
3905                last_report_label: Some("View Reports".to_string()),
3906                csp_nonce: csp_nonce.to_owned(),
3907            }
3908            .render()
3909            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3910            (StatusCode::NOT_FOUND, Html(html)).into_response()
3911        }
3912    }
3913}
3914
3915/// Serve the PDF artifact for a run — inline or download.
3916fn serve_pdf_artifact(
3917    path: &Path,
3918    report_title: &str,
3919    run_id: &str,
3920    wants_download: bool,
3921    csp_nonce: &str,
3922) -> Response {
3923    match fs::read(path) {
3924        Ok(bytes) => {
3925            let filename = build_pdf_filename(report_title, run_id);
3926            let disposition = if wants_download {
3927                format!("attachment; filename=\"{filename}\"")
3928            } else {
3929                format!("inline; filename=\"{filename}\"")
3930            };
3931            (
3932                [
3933                    (header::CONTENT_TYPE, "application/pdf".to_string()),
3934                    (header::CONTENT_DISPOSITION, disposition),
3935                ],
3936                bytes,
3937            )
3938                .into_response()
3939        }
3940        Err(err) => {
3941            let filename = path.file_name().map_or_else(
3942                || "report.pdf".to_string(),
3943                |n| n.to_string_lossy().into_owned(),
3944            );
3945            let msg = format!(
3946                "PDF report '{filename}' could not be read.\n\n\
3947                 Error: {err}\n\n\
3948                 If you moved or renamed the output folder, the stored path is now stale. \
3949                 Use 'Open PDF folder' from the results page to browse the output directory."
3950            );
3951            let html = ErrorTemplate {
3952                message: msg,
3953                last_report_url: Some("/view-reports".to_string()),
3954                last_report_label: Some("View Reports".to_string()),
3955                csp_nonce: csp_nonce.to_owned(),
3956            }
3957            .render()
3958            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3959            (StatusCode::NOT_FOUND, Html(html)).into_response()
3960        }
3961    }
3962}
3963
3964/// Serve the JSON artifact for a run — view or download.
3965fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3966    match fs::read(path) {
3967        Ok(bytes) => {
3968            if wants_download {
3969                (
3970                    [
3971                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
3972                        (
3973                            header::CONTENT_DISPOSITION,
3974                            "attachment; filename=result.json",
3975                        ),
3976                    ],
3977                    bytes,
3978                )
3979                    .into_response()
3980            } else {
3981                (
3982                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
3983                    bytes,
3984                )
3985                    .into_response()
3986            }
3987        }
3988        Err(err) => {
3989            let filename = path.file_name().map_or_else(
3990                || "result.json".to_string(),
3991                |n| n.to_string_lossy().into_owned(),
3992            );
3993            let msg = format!(
3994                "JSON result '{filename}' could not be read.\n\n\
3995                 Error: {err}\n\n\
3996                 If you moved or renamed the output folder, the stored path is now stale. \
3997                 Use 'Open JSON folder' from the results page to browse the output directory."
3998            );
3999            let html = ErrorTemplate {
4000                message: msg,
4001                last_report_url: Some("/view-reports".to_string()),
4002                last_report_label: Some("View Reports".to_string()),
4003                csp_nonce: csp_nonce.to_owned(),
4004            }
4005            .render()
4006            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4007            (StatusCode::NOT_FOUND, Html(html)).into_response()
4008        }
4009    }
4010}
4011
4012/// Recover a `RunArtifacts` from the persisted registry for a run ID.
4013fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
4014    let output_dir = entry
4015        .html_path
4016        .as_ref()
4017        .or(entry.json_path.as_ref())
4018        .or(entry.pdf_path.as_ref())
4019        .or(entry.csv_path.as_ref())
4020        .or(entry.xlsx_path.as_ref())
4021        .and_then(|p| p.parent().map(PathBuf::from))
4022        .unwrap_or_default();
4023    // Recover pdf_path: use the persisted one, or look for report.pdf
4024    // adjacent to html/json if only the old entries lack it.
4025    let pdf_path = entry.pdf_path.clone().or_else(|| {
4026        let candidate = output_dir.join("report.pdf");
4027        candidate.exists().then_some(candidate)
4028    });
4029    // csv_path / xlsx_path: persisted paths take precedence; fall back to
4030    // scanning the run directory for files matching the expected patterns so
4031    // that runs created before this feature still surface their artifacts.
4032    let csv_path = entry.csv_path.clone().or_else(|| {
4033        fs::read_dir(&output_dir).ok().and_then(|entries| {
4034            entries
4035                .filter_map(std::result::Result::ok)
4036                .find(|e| {
4037                    let n = e.file_name();
4038                    let n = n.to_string_lossy();
4039                    n.starts_with("report_") && n.ends_with(".csv")
4040                })
4041                .map(|e| e.path())
4042        })
4043    });
4044    let xlsx_path = entry.xlsx_path.clone().or_else(|| {
4045        fs::read_dir(&output_dir).ok().and_then(|entries| {
4046            entries
4047                .filter_map(std::result::Result::ok)
4048                .find(|e| {
4049                    let n = e.file_name();
4050                    let n = n.to_string_lossy();
4051                    n.starts_with("report_") && n.ends_with(".xlsx")
4052                })
4053                .map(|e| e.path())
4054        })
4055    });
4056    RunArtifacts {
4057        output_dir: output_dir.clone(),
4058        html_path: entry.html_path.clone(),
4059        pdf_path,
4060        json_path: entry.json_path.clone(),
4061        csv_path,
4062        xlsx_path,
4063        scan_config_path: find_scan_config_in_dir(&output_dir),
4064        report_title: entry.project_label.clone(),
4065        result_context: RunResultContext::default(),
4066    }
4067}
4068
4069#[allow(clippy::too_many_lines)]
4070async fn artifact_handler(
4071    // NOSONAR(rust:S3776)
4072    State(state): State<AppState>,
4073    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4074    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
4075    Query(query): Query<ArtifactQuery>,
4076) -> Response {
4077    let artifact_set = {
4078        let registry = state.artifacts.lock().await;
4079        registry.get(&run_id).cloned()
4080    };
4081
4082    // Fall back to the persisted registry when the server was restarted and the
4083    // in-memory artifact map no longer holds the entry.
4084    let artifact_set = if let Some(a) = artifact_set {
4085        a
4086    } else {
4087        let reg = state.registry.lock().await;
4088        if let Some(entry) = reg.find_by_run_id(&run_id) {
4089            recover_artifacts_from_registry(entry)
4090        } else {
4091            let short_id = &run_id[..run_id.len().min(8)];
4092            let hint = if matches!(
4093                run_id.as_str(),
4094                "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
4095            ) {
4096                format!(
4097                    " The URL format appears to be reversed — \
4098                     the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
4099                     Use the View Reports page to navigate to your scan."
4100                )
4101            } else {
4102                " The report may have been deleted or the report directory moved. \
4103                 Use View Reports to browse your scan history."
4104                    .to_string()
4105            };
4106            let error_html = ErrorTemplate {
4107                message: format!(
4108                    "Report not found. \"{short_id}\" is not a recognized run ID.{hint}"
4109                ),
4110                last_report_url: Some("/view-reports".to_string()),
4111                last_report_label: Some("View Reports".to_string()),
4112                csp_nonce: csp_nonce.clone(),
4113            }
4114            .render()
4115            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4116            return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
4117        }
4118    };
4119
4120    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
4121
4122    match artifact.as_str() {
4123        "html" => {
4124            let Some(path) = artifact_set.html_path else {
4125                return StatusCode::NOT_FOUND.into_response();
4126            };
4127            serve_html_artifact(&path, wants_download, &csp_nonce)
4128        }
4129        "pdf" => {
4130            let Some(path) = artifact_set.pdf_path else {
4131                let msg = "PDF report was not generated for this run, or was not recorded in \
4132                           the scan registry. Re-run the analysis with PDF output enabled."
4133                    .to_string();
4134                let html = ErrorTemplate {
4135                    message: msg,
4136                    last_report_url: Some(format!("/runs/html/{run_id}")),
4137                    last_report_label: Some("View HTML Report".to_string()),
4138                    csp_nonce: csp_nonce.clone(),
4139                }
4140                .render()
4141                .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
4142                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4143            };
4144            // PDF path is recorded but the background task may still be writing it.
4145            // Return a self-refreshing "please wait" page rather than an error.
4146            if !path.exists() {
4147                let html = format!(
4148                    "<!doctype html><html lang=\"en\"><head>\
4149                     <meta charset=utf-8>\
4150                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
4151                     <meta http-equiv=\"refresh\" content=\"5\">\
4152                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
4153                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
4154                     <style nonce=\"{csp_nonce}\">\
4155                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
4156                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
4157                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
4158                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
4159                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
4160                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
4161                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
4162                     background:var(--bg);color:var(--text);}}\
4163                     .top-nav{{position:sticky;top:0;z-index:30;\
4164                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
4165                     border-bottom:1px solid rgba(255,255,255,0.12);\
4166                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
4167                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
4168                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
4169                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
4170                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
4171                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
4172                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
4173                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
4174                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
4175                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
4176                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
4177                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
4178                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
4179                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
4180                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
4181                     justify-content:center;min-height:38px;border-radius:999px;\
4182                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
4183                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
4184                     .theme-toggle .icon-sun{{display:none;}}\
4185                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
4186                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
4187                     .page{{max-width:1720px;margin:0 auto;padding:60px 24px;\
4188                     display:flex;align-items:center;justify-content:center;\
4189                     min-height:calc(100vh - 56px);}}\
4190                     .panel{{background:var(--surface);border:1px solid var(--line);\
4191                     border-radius:var(--radius);box-shadow:var(--shadow);\
4192                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
4193                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
4194                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
4195                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
4196                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
4197                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
4198                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
4199                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
4200                     min-height:42px;padding:0 20px;border-radius:14px;\
4201                     border:1px solid var(--line-strong);text-decoration:none;\
4202                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
4203                     .back-link:hover{{background:var(--line);}}\
4204                     </style></head>\
4205                     <body>\
4206                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
4207                       <a class=\"brand\" href=\"/\">\
4208                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
4209                         <div class=\"brand-copy\">\
4210                           <div class=\"brand-title\">OxideSLOC</div>\
4211                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
4212                         </div>\
4213                       </a>\
4214                       <div class=\"nav-right\">\
4215                         <a class=\"nav-pill\" href=\"/\">Home</a>\
4216                         <div class=\"nav-dropdown\">\
4217                           <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>\
4218                           <div class=\"nav-dropdown-menu\">\
4219                             <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>\
4220                           </div>\
4221                         </div>\
4222                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
4223                           <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>\
4224                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
4225                           <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>\
4226                         </button>\
4227                       </div>\
4228                     </div></div>\
4229                     <div class=\"page\"><div class=\"panel\">\
4230                       <div class=\"spin-ring\"></div>\
4231                       <h1>Generating PDF\u{2026}</h1>\
4232                       <p>The PDF is being rendered from the HTML report.<br>\
4233                       This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
4234                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
4235                     </div></div>\
4236                     <script nonce=\"{csp_nonce}\">\
4237                     (function(){{\
4238                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
4239                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
4240                       var t=document.getElementById(\"theme-toggle\");\
4241                       if(t)t.addEventListener(\"click\",function(){{\
4242                         var d=b.classList.toggle(\"dark-theme\");\
4243                         localStorage.setItem(k,d?\"dark\":\"light\");\
4244                       }});\
4245                     }})();\
4246                     </script>\
4247                     </body></html>"
4248                );
4249                return Html(html).into_response();
4250            }
4251            serve_pdf_artifact(
4252                &path,
4253                &artifact_set.report_title,
4254                &run_id,
4255                wants_download,
4256                &csp_nonce,
4257            )
4258        }
4259        "json" => {
4260            let Some(path) = artifact_set.json_path else {
4261                let msg = "JSON result was not generated for this run, or was not recorded in \
4262                           the scan registry. Re-run the analysis with JSON output enabled."
4263                    .to_string();
4264                let html = ErrorTemplate {
4265                    message: msg,
4266                    last_report_url: Some("/view-reports".to_string()),
4267                    last_report_label: Some("View Reports".to_string()),
4268                    csp_nonce: csp_nonce.clone(),
4269                }
4270                .render()
4271                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
4272                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4273            };
4274            serve_json_artifact(&path, wants_download, &csp_nonce)
4275        }
4276        "csv" => {
4277            let Some(path) = artifact_set.csv_path else {
4278                let msg = "CSV report was not generated for this run, or was not recorded in \
4279                           the scan registry."
4280                    .to_string();
4281                let html = ErrorTemplate {
4282                    message: msg,
4283                    last_report_url: Some(format!("/runs/html/{run_id}")),
4284                    last_report_label: Some("View HTML Report".to_string()),
4285                    csp_nonce: csp_nonce.clone(),
4286                }
4287                .render()
4288                .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
4289                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4290            };
4291            fs::read(&path).map_or_else(
4292                |_| StatusCode::NOT_FOUND.into_response(),
4293                |bytes| {
4294                    let filename = path
4295                        .file_name()
4296                        .map(|n| n.to_string_lossy().into_owned())
4297                        .unwrap_or_else(|| "report.csv".to_string());
4298                    (
4299                        [
4300                            (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
4301                            (
4302                                header::CONTENT_DISPOSITION,
4303                                format!("attachment; filename=\"{filename}\""),
4304                            ),
4305                        ],
4306                        bytes,
4307                    )
4308                        .into_response()
4309                },
4310            )
4311        }
4312        "xlsx" => {
4313            let Some(path) = artifact_set.xlsx_path else {
4314                let msg = "Excel report was not generated for this run, or was not recorded in \
4315                           the scan registry."
4316                    .to_string();
4317                let html = ErrorTemplate {
4318                    message: msg,
4319                    last_report_url: Some(format!("/runs/html/{run_id}")),
4320                    last_report_label: Some("View HTML Report".to_string()),
4321                    csp_nonce: csp_nonce.clone(),
4322                }
4323                .render()
4324                .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
4325                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4326            };
4327            fs::read(&path).map_or_else(
4328                |_| StatusCode::NOT_FOUND.into_response(),
4329                |bytes| {
4330                    let filename = path
4331                        .file_name()
4332                        .map(|n| n.to_string_lossy().into_owned())
4333                        .unwrap_or_else(|| "report.xlsx".to_string());
4334                    (
4335                        [
4336                            (
4337                                header::CONTENT_TYPE,
4338                                "application/vnd.openxmlformats-officedocument\
4339                                 .spreadsheetml.sheet"
4340                                    .to_string(),
4341                            ),
4342                            (
4343                                header::CONTENT_DISPOSITION,
4344                                format!("attachment; filename=\"{filename}\""),
4345                            ),
4346                        ],
4347                        bytes,
4348                    )
4349                        .into_response()
4350                },
4351            )
4352        }
4353        "scan-config" => {
4354            let path = artifact_set
4355                .scan_config_path
4356                .as_deref()
4357                .map(std::path::Path::to_path_buf)
4358                .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
4359                .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
4360            fs::read(&path).map_or_else(
4361                |_| StatusCode::NOT_FOUND.into_response(),
4362                |bytes| {
4363                    (
4364                        [
4365                            (
4366                                header::CONTENT_TYPE,
4367                                "application/json; charset=utf-8".to_string(),
4368                            ),
4369                            (
4370                                header::CONTENT_DISPOSITION,
4371                                "attachment; filename=\"scan-config.json\"".to_string(),
4372                            ),
4373                        ],
4374                        bytes,
4375                    )
4376                        .into_response()
4377                },
4378            )
4379        }
4380        _ if artifact.starts_with("sub_") => {
4381            if artifact.len() > 128
4382                || !artifact
4383                    .chars()
4384                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4385            {
4386                return StatusCode::BAD_REQUEST.into_response();
4387            }
4388            let filename = format!("{artifact}.html");
4389            let path = artifact_set.output_dir.join(&filename);
4390            if !path.exists() {
4391                let html = ErrorTemplate {
4392                    message: format!(
4393                        "Sub-report '{artifact}' was not found in the run directory.\n\
4394                         Re-run the analysis with 'Detect and separate git submodules' \
4395                         and HTML output enabled."
4396                    ),
4397                    last_report_url: Some("/view-reports".to_string()),
4398                    last_report_label: Some("View Reports".to_string()),
4399                    csp_nonce: csp_nonce.clone(),
4400                }
4401                .render()
4402                .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
4403                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4404            }
4405            serve_html_artifact(&path, wants_download, &csp_nonce)
4406        }
4407        _ => StatusCode::NOT_FOUND.into_response(),
4408    }
4409}
4410
4411// ── History ───────────────────────────────────────────────────────────────────
4412
4413struct SubmoduleLinkRow {
4414    name: String,
4415    url: String,
4416}
4417
4418struct HistoryEntryRow {
4419    run_id: String,
4420    run_id_short: String,
4421    timestamp: String,
4422    timestamp_utc_ms: i64,
4423    project_label: String,
4424    project_path: String,
4425    files_analyzed: u64,
4426    files_skipped: u64,
4427    code_lines: u64,
4428    comment_lines: u64,
4429    blank_lines: u64,
4430    git_branch: String,
4431    git_commit: String,
4432    has_html: bool,
4433    has_json: bool,
4434    has_pdf: bool,
4435    submodule_links: Vec<SubmoduleLinkRow>,
4436    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
4437    submodule_names_csv: String,
4438}
4439
4440/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
4441fn nth_weekday_of_month(
4442    year: i32,
4443    month: u32,
4444    weekday: chrono::Weekday,
4445    n: u32,
4446) -> chrono::NaiveDate {
4447    use chrono::Datelike;
4448    let mut count = 0u32;
4449    let mut day = 1u32;
4450    loop {
4451        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
4452        if d.weekday() == weekday {
4453            count += 1;
4454            if count == n {
4455                return d;
4456            }
4457        }
4458        day += 1;
4459    }
4460}
4461
4462/// Returns true if `dt` falls within US Pacific Daylight Time.
4463/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
4464/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
4465fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
4466    use chrono::{Datelike, TimeZone};
4467    let year = dt.year();
4468    let dst_start = chrono::Utc.from_utc_datetime(
4469        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
4470            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
4471    );
4472    let dst_end = chrono::Utc.from_utc_datetime(
4473        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
4474            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
4475    );
4476    dt >= dst_start && dt < dst_end
4477}
4478
4479fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
4480    if is_pacific_dst(dt) {
4481        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
4482            .format("%Y-%m-%d %H:%M PDT")
4483            .to_string()
4484    } else {
4485        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
4486            .format("%Y-%m-%d %H:%M PST")
4487            .to_string()
4488    }
4489}
4490
4491fn fmt_git_date(iso: &str) -> Option<String> {
4492    chrono::DateTime::parse_from_rfc3339(iso)
4493        .ok()
4494        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
4495}
4496
4497fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
4498    reg.entries
4499        .iter()
4500        .map(|e| {
4501            let submodule_links = {
4502                let mut links: Vec<SubmoduleLinkRow> = vec![];
4503                let sub_dir = e
4504                    .html_path
4505                    .as_ref()
4506                    .and_then(|p| p.parent())
4507                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
4508                if let Some(dir) = sub_dir {
4509                    if let Ok(rd) = std::fs::read_dir(dir) {
4510                        for entry_res in rd.flatten() {
4511                            let fname = entry_res.file_name();
4512                            let fname_str = fname.to_string_lossy();
4513                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
4514                                let stem = &fname_str[..fname_str.len() - 5];
4515                                let display = stem[4..].replace('-', " ");
4516                                links.push(SubmoduleLinkRow {
4517                                    name: display,
4518                                    url: format!("/runs/{stem}/{}", e.run_id),
4519                                });
4520                            }
4521                        }
4522                    }
4523                }
4524                links.sort_by(|a, b| a.name.cmp(&b.name));
4525                links
4526            };
4527            let submodule_names_csv = submodule_links
4528                .iter()
4529                .map(|l| l.name.as_str())
4530                .collect::<Vec<_>>()
4531                .join(",");
4532            HistoryEntryRow {
4533                run_id: e.run_id.clone(),
4534                run_id_short: e
4535                    .run_id
4536                    .split('-')
4537                    .next_back()
4538                    .unwrap_or(&e.run_id)
4539                    .chars()
4540                    .take(7)
4541                    .collect(),
4542                timestamp: fmt_la_time(e.timestamp_utc),
4543                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
4544                project_label: e.project_label.clone(),
4545                project_path: e
4546                    .input_roots
4547                    .first()
4548                    .map(|s| sanitize_path_str(s))
4549                    .unwrap_or_default(),
4550                files_analyzed: e.summary.files_analyzed,
4551                files_skipped: e.summary.files_skipped,
4552                code_lines: e.summary.code_lines,
4553                comment_lines: e.summary.comment_lines,
4554                blank_lines: e.summary.blank_lines,
4555                git_branch: e.git_branch.clone().unwrap_or_default(),
4556                git_commit: e.git_commit.clone().unwrap_or_default(),
4557                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
4558                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
4559                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
4560                submodule_links,
4561                submodule_names_csv,
4562            }
4563        })
4564        .collect()
4565}
4566
4567#[derive(Deserialize, Default)]
4568struct HistoryQuery {
4569    linked: Option<String>,
4570    error: Option<String>,
4571}
4572
4573async fn history_handler(
4574    State(state): State<AppState>,
4575    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4576    Query(query): Query<HistoryQuery>,
4577) -> impl IntoResponse {
4578    // Auto-scan all watched directories before rendering so the list stays fresh.
4579    auto_scan_watched_dirs(&state).await;
4580    let watched_dirs: Vec<String> = {
4581        let wd = state.watched_dirs.lock().await;
4582        wd.dirs.iter().map(|p| p.display().to_string()).collect()
4583    };
4584    let mut entries = {
4585        let reg = state.registry.lock().await;
4586        make_history_rows(&reg)
4587    };
4588    entries.retain(|e| e.has_html);
4589    let total_scans = entries.len();
4590    let linked_count = query
4591        .linked
4592        .as_deref()
4593        .and_then(|s| s.parse::<usize>().ok())
4594        .unwrap_or(0);
4595    let browse_error = query.error.filter(|s| !s.is_empty());
4596    let template = HistoryTemplate {
4597        version: env!("CARGO_PKG_VERSION"),
4598        entries,
4599        total_scans,
4600        linked_count,
4601        browse_error,
4602        watched_dirs,
4603        csp_nonce,
4604    };
4605    Html(
4606        template
4607            .render()
4608            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4609    )
4610    .into_response()
4611}
4612
4613async fn compare_select_handler(
4614    State(state): State<AppState>,
4615    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4616) -> impl IntoResponse {
4617    auto_scan_watched_dirs(&state).await;
4618    let watched_dirs: Vec<String> = {
4619        let wd = state.watched_dirs.lock().await;
4620        wd.dirs.iter().map(|p| p.display().to_string()).collect()
4621    };
4622    let mut entries = {
4623        let reg = state.registry.lock().await;
4624        make_history_rows(&reg)
4625    };
4626    entries.retain(|e| e.has_json);
4627    let total_scans = entries.len();
4628    let template = CompareSelectTemplate {
4629        version: env!("CARGO_PKG_VERSION"),
4630        entries,
4631        total_scans,
4632        watched_dirs,
4633        csp_nonce,
4634    };
4635    Html(
4636        template
4637            .render()
4638            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4639    )
4640    .into_response()
4641}
4642
4643// ── Compare ───────────────────────────────────────────────────────────────────
4644
4645#[derive(Deserialize, Default)]
4646struct CompareQuery {
4647    a: Option<String>,
4648    b: Option<String>,
4649    /// Optional submodule name to scope the comparison to one submodule.
4650    sub: Option<String>,
4651    /// "super" to exclude all submodule files and show only the super-repo.
4652    scope: Option<String>,
4653}
4654
4655struct CompareFileDeltaRow {
4656    relative_path: String,
4657    language: String,
4658    status: String,
4659    baseline_code: i64,
4660    current_code: i64,
4661    code_delta_str: String,
4662    code_delta_class: String,
4663    comment_delta_str: String,
4664    comment_delta_class: String,
4665    total_delta_str: String,
4666    total_delta_class: String,
4667}
4668
4669/// Recompute `summary_totals` from the current `per_file_records` slice.
4670/// Used when `per_file_records` has been narrowed to a submodule subset.
4671fn recompute_summary_from_records(run: &mut AnalysisRun) {
4672    let files_analyzed = run
4673        .per_file_records
4674        .iter()
4675        .filter(|r| r.language.is_some())
4676        .count() as u64;
4677    let code_lines: u64 = run
4678        .per_file_records
4679        .iter()
4680        .map(|r| r.effective_counts.code_lines)
4681        .sum();
4682    let comment_lines: u64 = run
4683        .per_file_records
4684        .iter()
4685        .map(|r| r.effective_counts.comment_lines)
4686        .sum();
4687    let blank_lines: u64 = run
4688        .per_file_records
4689        .iter()
4690        .map(|r| r.effective_counts.blank_lines)
4691        .sum();
4692    run.summary_totals.files_analyzed = files_analyzed;
4693    run.summary_totals.files_considered = files_analyzed;
4694    run.summary_totals.code_lines = code_lines;
4695    run.summary_totals.comment_lines = comment_lines;
4696    run.summary_totals.blank_lines = blank_lines;
4697    run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
4698}
4699
4700fn fmt_delta(n: i64) -> String {
4701    if n > 0 {
4702        format!("+{n}")
4703    } else {
4704        format!("{n}")
4705    }
4706}
4707
4708fn delta_class(n: i64) -> &'static str {
4709    use std::cmp::Ordering;
4710    match n.cmp(&0) {
4711        Ordering::Greater => "pos",
4712        Ordering::Less => "neg",
4713        Ordering::Equal => "zero",
4714    }
4715}
4716
4717// ratio/percentage display, precision loss acceptable
4718#[allow(clippy::cast_precision_loss)]
4719fn fmt_pct(delta: i64, baseline: u64) -> String {
4720    if baseline == 0 {
4721        return "—".to_string();
4722    }
4723    #[allow(clippy::cast_precision_loss)]
4724    let pct = (delta as f64 / baseline as f64) * 100.0;
4725    if pct > 0.049 {
4726        format!("+{pct:.1}%")
4727    } else if pct < -0.049 {
4728        format!("{pct:.1}%")
4729    } else {
4730        "±0%".to_string()
4731    }
4732}
4733
4734/// Returns (`display_string`, `css_class`) for a numeric change column cell.
4735fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
4736    prev.map_or_else(
4737        || ("—".to_string(), "na"),
4738        |p| {
4739            #[allow(clippy::cast_possible_wrap)]
4740            let d = curr as i64 - p as i64;
4741            (fmt_delta(d), delta_class(d))
4742        },
4743    )
4744}
4745
4746#[allow(clippy::too_many_lines)]
4747async fn compare_handler(
4748    // NOSONAR(rust:S3776)
4749    State(state): State<AppState>,
4750    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4751    Query(query): Query<CompareQuery>,
4752) -> impl IntoResponse {
4753    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
4754    // redirect to the history page where the user can select two runs.
4755    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
4756        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
4757        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
4758    };
4759
4760    let (maybe_a, maybe_b) = {
4761        let reg = state.registry.lock().await;
4762        (
4763            reg.find_by_run_id(&run_id_a).cloned(),
4764            reg.find_by_run_id(&run_id_b).cloned(),
4765        )
4766    };
4767
4768    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
4769        let html = ErrorTemplate {
4770            message: "One or both run IDs were not found in scan history. \
4771                      The runs may have been deleted or the registry may have been reset."
4772                .to_string(),
4773            last_report_url: Some("/compare-scans".to_string()),
4774            last_report_label: Some("Compare Scans".to_string()),
4775            csp_nonce: csp_nonce.clone(),
4776        }
4777        .render()
4778        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
4779        return Html(html).into_response();
4780    };
4781
4782    // Ensure older scan is always the baseline.
4783    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
4784        (entry_a, entry_b)
4785    } else {
4786        (entry_b, entry_a)
4787    };
4788
4789    // If query params were in the wrong order, redirect to canonical URL so the
4790    // browser always shows the same URL for the same two scans regardless of how
4791    // the user arrived here (Full diff button vs. Compare Scans selection).
4792    if baseline_entry.run_id != run_id_a {
4793        let canonical = format!(
4794            "/compare?a={}&b={}",
4795            baseline_entry.run_id, current_entry.run_id
4796        );
4797        return axum::response::Redirect::to(&canonical).into_response();
4798    }
4799
4800    let (Some(base_json), Some(curr_json)) = (
4801        baseline_entry.json_path.as_ref(),
4802        current_entry.json_path.as_ref(),
4803    ) else {
4804        let html = ErrorTemplate {
4805            message: "Full comparison requires JSON scan data, which was not saved for one or \
4806                      both of these runs. JSON is now always saved for new scans — re-run the \
4807                      affected projects to enable comparisons."
4808                .to_string(),
4809            last_report_url: Some("/compare-scans".to_string()),
4810            last_report_label: Some("Compare Scans".to_string()),
4811            csp_nonce: csp_nonce.clone(),
4812        }
4813        .render()
4814        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
4815        return Html(html).into_response();
4816    };
4817
4818    let compare_url = format!(
4819        "/compare?a={}&b={}",
4820        baseline_entry.run_id, current_entry.run_id
4821    );
4822
4823    let baseline_run = match read_json(base_json) {
4824        Ok(r) => r,
4825        Err(e) => {
4826            if state.server_mode {
4827                let html = ErrorTemplate {
4828                    message: "Could not load baseline scan data. The scan output folder may \
4829                              have been moved, renamed, or deleted. Re-running the analysis \
4830                              will create fresh comparison data."
4831                        .to_string(),
4832                    last_report_url: Some("/compare-scans".to_string()),
4833                    last_report_label: Some("Compare Scans".to_string()),
4834                    csp_nonce: csp_nonce.clone(),
4835                }
4836                .render()
4837                .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
4838                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4839            }
4840            let msg = format!(
4841                "Could not load baseline scan data.\n\nExpected path: {}\n\nError: {e}",
4842                base_json.display()
4843            );
4844            let folder_hint = base_json
4845                .parent()
4846                .map(|p| p.display().to_string())
4847                .unwrap_or_default();
4848            return missing_scan_relocate_response(
4849                &msg,
4850                &baseline_entry.run_id,
4851                &folder_hint,
4852                &compare_url,
4853                false,
4854                &csp_nonce,
4855            );
4856        }
4857    };
4858    let current_run = match read_json(curr_json) {
4859        Ok(r) => r,
4860        Err(e) => {
4861            if state.server_mode {
4862                let html = ErrorTemplate {
4863                    message: "Could not load current scan data. The scan output folder may \
4864                              have been moved, renamed, or deleted. Re-running the analysis \
4865                              will create fresh comparison data."
4866                        .to_string(),
4867                    last_report_url: Some("/compare-scans".to_string()),
4868                    last_report_label: Some("Compare Scans".to_string()),
4869                    csp_nonce: csp_nonce.clone(),
4870                }
4871                .render()
4872                .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
4873                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4874            }
4875            let msg = format!(
4876                "Could not load current scan data.\n\nExpected path: {}\n\nError: {e}",
4877                curr_json.display()
4878            );
4879            let folder_hint = curr_json
4880                .parent()
4881                .map(|p| p.display().to_string())
4882                .unwrap_or_default();
4883            return missing_scan_relocate_response(
4884                &msg,
4885                &current_entry.run_id,
4886                &folder_hint,
4887                &compare_url,
4888                false,
4889                &csp_nonce,
4890            );
4891        }
4892    };
4893
4894    let active_submodule = query.sub.clone();
4895    let super_scope_active = query.scope.as_deref() == Some("super");
4896
4897    // Build the union of submodule names present in either run so users can
4898    // scope to a submodule even when it only exists in one of the two scans.
4899    let submodule_options = {
4900        let mut names = std::collections::BTreeSet::new();
4901        for s in &baseline_run.submodule_summaries {
4902            names.insert(s.name.clone());
4903        }
4904        for s in &current_run.submodule_summaries {
4905            names.insert(s.name.clone());
4906        }
4907        names.into_iter().collect::<Vec<_>>()
4908    };
4909    let has_any_submodule_data = !submodule_options.is_empty();
4910
4911    // Narrow per_file_records when a scope is active, then recompute totals.
4912    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
4913        let mut b = baseline_run;
4914        let mut c = current_run;
4915        b.per_file_records
4916            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4917        c.per_file_records
4918            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4919        recompute_summary_from_records(&mut b);
4920        recompute_summary_from_records(&mut c);
4921        (b, c)
4922    } else if super_scope_active {
4923        let mut b = baseline_run;
4924        let mut c = current_run;
4925        b.per_file_records.retain(|f| f.submodule.is_none());
4926        c.per_file_records.retain(|f| f.submodule.is_none());
4927        recompute_summary_from_records(&mut b);
4928        recompute_summary_from_records(&mut c);
4929        (b, c)
4930    } else {
4931        (baseline_run, current_run)
4932    };
4933
4934    let comparison = compute_delta(&effective_baseline, &effective_current);
4935
4936    let file_rows: Vec<CompareFileDeltaRow> = comparison
4937        .file_deltas
4938        .iter()
4939        .map(|d| CompareFileDeltaRow {
4940            relative_path: d.relative_path.clone(),
4941            language: d.language.clone().unwrap_or_else(|| "—".into()),
4942            status: match d.status {
4943                FileChangeStatus::Added => "added".into(),
4944                FileChangeStatus::Removed => "removed".into(),
4945                FileChangeStatus::Modified => "modified".into(),
4946                FileChangeStatus::Unchanged => "unchanged".into(),
4947            },
4948            baseline_code: d.baseline_code,
4949            current_code: d.current_code,
4950            code_delta_str: fmt_delta(d.code_delta),
4951            code_delta_class: delta_class(d.code_delta).into(),
4952            comment_delta_str: fmt_delta(d.comment_delta),
4953            comment_delta_class: delta_class(d.comment_delta).into(),
4954            total_delta_str: fmt_delta(d.total_delta),
4955            total_delta_class: delta_class(d.total_delta).into(),
4956        })
4957        .collect();
4958
4959    let project_path = baseline_entry
4960        .input_roots
4961        .first()
4962        .map(|s| sanitize_path_str(s))
4963        .unwrap_or_default();
4964    let lines_added = sum_added_code_lines(&comparison);
4965    let lines_removed = sum_removed_code_lines(&comparison);
4966    // True when the selected scope had no files in the baseline — e.g. comparing a submodule
4967    // that only exists in the current scan or using Super-repo only on an older scan.
4968    let new_scope = comparison.summary.baseline_code == 0 && comparison.summary.current_code > 0;
4969    // ratio/percentage display, precision loss acceptable
4970    #[allow(clippy::cast_precision_loss)]
4971    let churn_pct = if comparison.summary.baseline_code > 0 {
4972        (lines_added + lines_removed) as f64 / comparison.summary.baseline_code as f64 * 100.0
4973    } else {
4974        0.0
4975    };
4976    #[allow(clippy::cast_precision_loss)]
4977    let scope_flag = new_scope
4978        || (comparison.summary.baseline_code > 0
4979            && lines_added as f64 / comparison.summary.baseline_code as f64 > 0.20);
4980    let s = &comparison.summary;
4981    let template = CompareTemplate {
4982        version: env!("CARGO_PKG_VERSION"),
4983        project_label: baseline_entry.project_label.clone(),
4984        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
4985        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
4986        baseline_run_id: baseline_entry.run_id.clone(),
4987        current_run_id: current_entry.run_id.clone(),
4988        baseline_run_id_short: baseline_entry
4989            .run_id
4990            .split('-')
4991            .next_back()
4992            .unwrap_or(&baseline_entry.run_id)
4993            .chars()
4994            .take(7)
4995            .collect(),
4996        current_run_id_short: current_entry
4997            .run_id
4998            .split('-')
4999            .next_back()
5000            .unwrap_or(&current_entry.run_id)
5001            .chars()
5002            .take(7)
5003            .collect(),
5004        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
5005        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
5006        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
5007        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
5008        project_path: project_path.clone(),
5009        baseline_code: s.baseline_code,
5010        current_code: s.current_code,
5011        code_lines_delta_str: fmt_delta(s.code_lines_delta),
5012        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
5013        baseline_files: s.baseline_files,
5014        current_files: s.current_files,
5015        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
5016        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
5017        baseline_comments: s.baseline_comments,
5018        current_comments: s.current_comments,
5019        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
5020        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
5021        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
5022        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
5023        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
5024        code_lines_added: lines_added,
5025        code_lines_removed: lines_removed,
5026        new_scope,
5027        churn_rate_str: if new_scope {
5028            "New".to_string()
5029        } else if s.baseline_code > 0 {
5030            format!("{churn_pct:.1}%")
5031        } else {
5032            "—".to_string()
5033        },
5034        churn_rate_class: if new_scope || churn_pct > 20.0 {
5035            "high".into()
5036        } else if churn_pct > 5.0 {
5037            "med".into()
5038        } else {
5039            "low".into()
5040        },
5041        scope_flag,
5042        files_added: comparison.files_added,
5043        files_removed: comparison.files_removed,
5044        files_modified: comparison.files_modified,
5045        files_unchanged: comparison.files_unchanged,
5046        file_rows,
5047        baseline_git_author: baseline_entry.git_author.clone(),
5048        current_git_author: current_entry.git_author.clone(),
5049        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
5050        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
5051        baseline_git_tags: baseline_entry.git_tags.clone(),
5052        current_git_tags: current_entry.git_tags.clone(),
5053        baseline_git_commit_date: baseline_entry
5054            .git_commit_date
5055            .as_deref()
5056            .and_then(fmt_git_date),
5057        current_git_commit_date: current_entry
5058            .git_commit_date
5059            .as_deref()
5060            .and_then(fmt_git_date),
5061        project_name: project_path
5062            .rsplit(['/', '\\'])
5063            .find(|s| !s.is_empty())
5064            .unwrap_or(&project_path)
5065            .to_string(),
5066        submodule_options,
5067        has_any_submodule_data,
5068        active_submodule,
5069        super_scope_active,
5070        csp_nonce,
5071    };
5072
5073    Html(
5074        template
5075            .render()
5076            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5077    )
5078    .into_response()
5079}
5080
5081// ── Badge endpoint ────────────────────────────────────────────────────────────
5082// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
5083// pages, Jira descriptions, etc.
5084//
5085// GET /badge/<metric>?label=<override>&color=<hex>
5086// Metrics: code-lines  files  comment-lines  blank-lines
5087
5088fn format_number(n: u64) -> String {
5089    let s = n.to_string();
5090    let mut out = String::with_capacity(s.len() + s.len() / 3);
5091    let len = s.len();
5092    for (i, c) in s.chars().enumerate() {
5093        if i > 0 && (len - i).is_multiple_of(3) {
5094            out.push(',');
5095        }
5096        out.push(c);
5097    }
5098    out
5099}
5100
5101const fn badge_char_width(c: char) -> f64 {
5102    match c {
5103        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
5104        'm' | 'w' => 9.0,
5105        ' ' => 4.0,
5106        _ => 6.5,
5107    }
5108}
5109
5110#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5111fn badge_text_px(text: &str) -> u32 {
5112    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
5113}
5114
5115fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
5116    let lw = badge_text_px(label) + 20;
5117    let rw = badge_text_px(value) + 20;
5118    let total = lw + rw;
5119    let lx = lw / 2;
5120    let rx = lw + rw / 2;
5121    let le = escape_html(label);
5122    let ve = escape_html(value);
5123    let ce = escape_html(color);
5124    format!(
5125        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
5126  <rect width="{total}" height="20" fill="#555"/>
5127  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
5128  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
5129    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
5130    <text x="{lx}" y="13">{le}</text>
5131    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
5132    <text x="{rx}" y="13">{ve}</text>
5133  </g>
5134</svg>"##
5135    )
5136}
5137
5138#[derive(Deserialize)]
5139struct BadgeQuery {
5140    label: Option<String>,
5141    color: Option<String>,
5142}
5143
5144async fn badge_handler(
5145    State(state): State<AppState>,
5146    AxumPath(metric): AxumPath<String>,
5147    Query(query): Query<BadgeQuery>,
5148) -> Response {
5149    let entry = {
5150        let reg = state.registry.lock().await;
5151        reg.entries.first().cloned()
5152    };
5153
5154    let Some(entry) = entry else {
5155        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
5156        return (
5157            [
5158                (header::CONTENT_TYPE, "image/svg+xml"),
5159                (header::CACHE_CONTROL, "no-cache, max-age=0"),
5160            ],
5161            svg,
5162        )
5163            .into_response();
5164    };
5165
5166    let (default_label, value, default_color) = match metric.as_str() {
5167        "code-lines" => (
5168            "code lines",
5169            format_number(entry.summary.code_lines),
5170            "#4a78ee",
5171        ),
5172        "files" => (
5173            "files analyzed",
5174            format_number(entry.summary.files_analyzed),
5175            "#4a9862",
5176        ),
5177        "comment-lines" => (
5178            "comment lines",
5179            format_number(entry.summary.comment_lines),
5180            "#b35428",
5181        ),
5182        "blank-lines" => (
5183            "blank lines",
5184            format_number(entry.summary.blank_lines),
5185            "#7a5db0",
5186        ),
5187        _ => return StatusCode::NOT_FOUND.into_response(),
5188    };
5189
5190    let label = query.label.as_deref().unwrap_or(default_label);
5191    let color = query.color.as_deref().unwrap_or(default_color);
5192    let svg = render_badge_svg(label, &value, color);
5193
5194    (
5195        [
5196            (header::CONTENT_TYPE, "image/svg+xml"),
5197            (header::CACHE_CONTROL, "no-cache, max-age=0"),
5198        ],
5199        svg,
5200    )
5201        .into_response()
5202}
5203
5204// ── Metrics API ───────────────────────────────────────────────────────────────
5205// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
5206// Confluence automation, Jira webhooks, etc.
5207//
5208// GET /api/metrics/latest
5209// GET /api/metrics/<run_id>
5210
5211#[derive(Serialize)]
5212struct ApiMetricsResponse {
5213    run_id: String,
5214    timestamp: String,
5215    project: String,
5216    summary: ApiSummaryPayload,
5217    languages: Vec<ApiLanguageRow>,
5218}
5219
5220#[derive(Serialize)]
5221struct ApiSummaryPayload {
5222    files_analyzed: u64,
5223    files_skipped: u64,
5224    code_lines: u64,
5225    comment_lines: u64,
5226    blank_lines: u64,
5227    total_physical_lines: u64,
5228    functions: u64,
5229    classes: u64,
5230    variables: u64,
5231    imports: u64,
5232}
5233
5234#[derive(Serialize)]
5235struct ApiLanguageRow {
5236    name: String,
5237    files: u64,
5238    code_lines: u64,
5239    comment_lines: u64,
5240    blank_lines: u64,
5241    functions: u64,
5242    classes: u64,
5243    variables: u64,
5244    imports: u64,
5245}
5246
5247async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
5248    let entry = {
5249        let reg = state.registry.lock().await;
5250        reg.entries.first().cloned()
5251    };
5252    entry.map_or_else(
5253        || {
5254            (
5255                StatusCode::NOT_FOUND,
5256                Json(serde_json::json!({"error": "no scans recorded yet"})),
5257            )
5258                .into_response()
5259        },
5260        |e| build_metrics_response(&e),
5261    )
5262}
5263
5264async fn api_metrics_run_handler(
5265    State(state): State<AppState>,
5266    AxumPath(run_id): AxumPath<String>,
5267) -> Response {
5268    let entry = {
5269        let reg = state.registry.lock().await;
5270        reg.find_by_run_id(&run_id).cloned()
5271    };
5272    entry.map_or_else(
5273        || {
5274            (
5275                StatusCode::NOT_FOUND,
5276                Json(serde_json::json!({"error": "run not found"})),
5277            )
5278                .into_response()
5279        },
5280        |e| build_metrics_response(&e),
5281    )
5282}
5283
5284fn build_metrics_response(entry: &RegistryEntry) -> Response {
5285    let languages: Vec<ApiLanguageRow> = entry
5286        .json_path
5287        .as_ref()
5288        .and_then(|p| read_json(p).ok())
5289        .map(|run| {
5290            run.totals_by_language
5291                .iter()
5292                .map(|l| ApiLanguageRow {
5293                    name: l.language.display_name().to_string(),
5294                    files: l.files,
5295                    code_lines: l.code_lines,
5296                    comment_lines: l.comment_lines,
5297                    blank_lines: l.blank_lines,
5298                    functions: l.functions,
5299                    classes: l.classes,
5300                    variables: l.variables,
5301                    imports: l.imports,
5302                })
5303                .collect()
5304        })
5305        .unwrap_or_default();
5306
5307    let s = &entry.summary;
5308    Json(ApiMetricsResponse {
5309        run_id: entry.run_id.clone(),
5310        timestamp: entry.timestamp_utc.to_rfc3339(),
5311        project: entry.project_label.clone(),
5312        summary: ApiSummaryPayload {
5313            files_analyzed: s.files_analyzed,
5314            files_skipped: s.files_skipped,
5315            code_lines: s.code_lines,
5316            comment_lines: s.comment_lines,
5317            blank_lines: s.blank_lines,
5318            total_physical_lines: s.total_physical_lines,
5319            functions: s.functions,
5320            classes: s.classes,
5321            variables: s.variables,
5322            imports: s.imports,
5323        },
5324        languages,
5325    })
5326    .into_response()
5327}
5328
5329// ── Project history API ───────────────────────────────────────────────────────
5330// Protected. Called by the wizard JS when the project path changes, so the UI
5331// can show a "scanned N times before" badge without a full page reload.
5332//
5333// GET /api/project-history?path=<project_root>
5334
5335#[derive(Deserialize)]
5336struct ProjectHistoryQuery {
5337    path: Option<String>,
5338}
5339
5340#[derive(Serialize)]
5341struct ProjectHistoryResponse {
5342    scan_count: usize,
5343    last_scan_id: Option<String>,
5344    last_scan_timestamp: Option<String>,
5345    last_scan_code_lines: Option<u64>,
5346    last_git_branch: Option<String>,
5347    last_git_commit: Option<String>,
5348}
5349
5350async fn project_history_handler(
5351    State(state): State<AppState>,
5352    Query(query): Query<ProjectHistoryQuery>,
5353) -> Response {
5354    let path = query.path.unwrap_or_default();
5355    let resolved = resolve_input_path(&path);
5356    let root_str = resolved.to_string_lossy().replace('\\', "/");
5357
5358    let entries: Vec<_> = {
5359        let reg = state.registry.lock().await;
5360        reg.entries
5361            .iter()
5362            .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
5363            .cloned()
5364            .collect()
5365    };
5366    let scan_count = entries.len();
5367    let last = entries.first();
5368    let last_scan_id = last.map(|e| e.run_id.clone());
5369    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
5370    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
5371    let last_git_branch = last.and_then(|e| e.git_branch.clone());
5372    let last_git_commit = last.and_then(|e| e.git_commit.clone());
5373
5374    Json(ProjectHistoryResponse {
5375        scan_count,
5376        last_scan_id,
5377        last_scan_timestamp,
5378        last_scan_code_lines,
5379        last_git_branch,
5380        last_git_commit,
5381    })
5382    .into_response()
5383}
5384
5385// ── Metrics history API ───────────────────────────────────────────────────────
5386// Protected. Returns a JSON array of lightweight scan snapshots for plotting
5387// trend charts.
5388//
5389// GET /api/metrics/history?root=<path>&limit=<n>
5390
5391#[derive(Deserialize)]
5392struct MetricsHistoryQuery {
5393    root: Option<String>,
5394    limit: Option<usize>,
5395    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
5396    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
5397    submodule: Option<String>,
5398}
5399
5400#[derive(Serialize)]
5401struct MetricsSubmoduleLink {
5402    name: String,
5403    url: String,
5404}
5405
5406#[derive(Serialize)]
5407struct MetricsHistoryEntry {
5408    run_id: String,
5409    run_id_short: String,
5410    timestamp: String,
5411    commit: Option<String>,
5412    branch: Option<String>,
5413    tags: Vec<String>,
5414    nearest_tag: Option<String>,
5415    code_lines: u64,
5416    comment_lines: u64,
5417    blank_lines: u64,
5418    physical_lines: u64,
5419    files_analyzed: u64,
5420    files_skipped: u64,
5421    test_count: u64,
5422    project_label: String,
5423    html_url: Option<String>,
5424    has_pdf: bool,
5425    submodule_links: Vec<MetricsSubmoduleLink>,
5426}
5427
5428#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
5429async fn api_metrics_history_handler(
5430    // NOSONAR(rust:S3776)
5431    State(state): State<AppState>,
5432    Query(query): Query<MetricsHistoryQuery>,
5433) -> Response {
5434    let limit = query.limit.unwrap_or(50).min(500);
5435    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
5436
5437    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
5438        let reg = state.registry.lock().await;
5439        reg.entries
5440            .iter()
5441            .filter(|e| {
5442                query.root.as_ref().is_none_or(|root| {
5443                    let resolved = resolve_input_path(root);
5444                    let root_str = resolved.to_string_lossy().replace('\\', "/");
5445                    e.input_roots.iter().any(|r| r == &root_str)
5446                })
5447            })
5448            .take(limit)
5449            .cloned()
5450            .collect()
5451    };
5452
5453    let entries: Vec<MetricsHistoryEntry> = candidate_entries
5454        .into_iter()
5455        .filter_map(|e| {
5456            let tags = e
5457                .git_tags
5458                .as_deref()
5459                .map(|s| {
5460                    s.split(',')
5461                        .map(|t| t.trim().to_string())
5462                        .filter(|t| !t.is_empty())
5463                        .collect()
5464                })
5465                .unwrap_or_default();
5466            let html_url = e
5467                .html_path
5468                .as_ref()
5469                .filter(|p| p.exists())
5470                .map(|_| format!("/runs/html/{}", e.run_id));
5471            let nearest_tag = e.git_nearest_tag.clone();
5472            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
5473            let run_id_short: String = e
5474                .run_id
5475                .split('-')
5476                .next_back()
5477                .unwrap_or(&e.run_id)
5478                .chars()
5479                .take(7)
5480                .collect();
5481            let submodule_links: Vec<MetricsSubmoduleLink> = {
5482                let mut links: Vec<MetricsSubmoduleLink> = vec![];
5483                let sub_dir = e
5484                    .html_path
5485                    .as_ref()
5486                    .and_then(|p| p.parent())
5487                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5488                if let Some(dir) = sub_dir {
5489                    if let Ok(rd) = std::fs::read_dir(dir) {
5490                        for entry_res in rd.flatten() {
5491                            let fname = entry_res.file_name();
5492                            let fname_str = fname.to_string_lossy();
5493                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5494                                let stem = &fname_str[..fname_str.len() - 5];
5495                                let display = stem[4..].replace('-', " ");
5496                                links.push(MetricsSubmoduleLink {
5497                                    name: display,
5498                                    url: format!("/runs/{stem}/{}", e.run_id),
5499                                });
5500                            }
5501                        }
5502                    }
5503                }
5504                links.sort_by(|a, b| a.name.cmp(&b.name));
5505                links
5506            };
5507            let base = MetricsHistoryEntry {
5508                run_id: e.run_id.clone(),
5509                run_id_short,
5510                timestamp: e.timestamp_utc.to_rfc3339(),
5511                commit: e.git_commit.clone(),
5512                branch: e.git_branch.clone(),
5513                tags,
5514                nearest_tag,
5515                code_lines: e.summary.code_lines,
5516                comment_lines: e.summary.comment_lines,
5517                blank_lines: e.summary.blank_lines,
5518                physical_lines: e.summary.total_physical_lines,
5519                files_analyzed: e.summary.files_analyzed,
5520                files_skipped: e.summary.files_skipped,
5521                test_count: e.summary.test_count,
5522                project_label: e.project_label.clone(),
5523                html_url,
5524                has_pdf,
5525                submodule_links,
5526            };
5527            if let Some(ref filter) = submodule_filter {
5528                // Read the full JSON artifact to get per-submodule metrics.
5529                let json_path = e.json_path.as_ref()?;
5530                let json_str = std::fs::read_to_string(json_path).ok()?;
5531                let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
5532                let sub = run.submodule_summaries.iter().find(|s| {
5533                    s.name.to_lowercase() == *filter || s.relative_path.to_lowercase() == *filter
5534                })?;
5535                // Point the report link to the submodule sub-report if it was generated.
5536                let safe = sanitize_project_label(&sub.name);
5537                let artifact_key = format!("sub_{safe}");
5538                let sub_html_url = if let Some(run_dir) = std::path::Path::new(json_path).parent() {
5539                    let sub_path = run_dir.join(format!("{artifact_key}.html"));
5540                    if sub_path.exists() {
5541                        Some(format!("/runs/{artifact_key}/{}", e.run_id))
5542                    } else {
5543                        base.html_url.clone()
5544                    }
5545                } else {
5546                    base.html_url.clone()
5547                };
5548                Some(MetricsHistoryEntry {
5549                    code_lines: sub.code_lines,
5550                    comment_lines: sub.comment_lines,
5551                    blank_lines: sub.blank_lines,
5552                    physical_lines: sub.total_physical_lines,
5553                    files_analyzed: sub.files_analyzed,
5554                    html_url: sub_html_url,
5555                    has_pdf: false,
5556                    submodule_links: vec![],
5557                    ..base
5558                })
5559            } else {
5560                Some(base)
5561            }
5562        })
5563        .collect();
5564
5565    Json(entries).into_response()
5566}
5567
5568// GET /api/metrics/submodules?root=<path>
5569// Returns the union of distinct submodule names found across all saved scan JSON artifacts
5570// for the given project root (or all roots if omitted).
5571#[derive(Deserialize)]
5572struct MetricsSubmodulesQuery {
5573    root: Option<String>,
5574}
5575
5576#[derive(Serialize)]
5577struct SubmoduleEntry {
5578    name: String,
5579    relative_path: String,
5580}
5581
5582async fn api_metrics_submodules_handler(
5583    State(state): State<AppState>,
5584    Query(query): Query<MetricsSubmodulesQuery>,
5585) -> Response {
5586    let json_paths: Vec<std::path::PathBuf> = {
5587        let reg = state.registry.lock().await;
5588        reg.entries
5589            .iter()
5590            .filter(|e| {
5591                query.root.as_ref().is_none_or(|root| {
5592                    let resolved = resolve_input_path(root);
5593                    let root_str = resolved.to_string_lossy().replace('\\', "/");
5594                    e.input_roots.iter().any(|r| r == &root_str)
5595                })
5596            })
5597            .filter_map(|e| e.json_path.clone())
5598            .collect()
5599    };
5600
5601    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5602    let mut result: Vec<SubmoduleEntry> = Vec::new();
5603
5604    for path in &json_paths {
5605        let Ok(json_str) = std::fs::read_to_string(path) else {
5606            continue;
5607        };
5608        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
5609            continue;
5610        };
5611        for sub in &run.submodule_summaries {
5612            if seen.insert(sub.name.clone()) {
5613                result.push(SubmoduleEntry {
5614                    name: sub.name.clone(),
5615                    relative_path: sub.relative_path.clone(),
5616                });
5617            }
5618        }
5619    }
5620
5621    result.sort_by(|a, b| a.name.cmp(&b.name));
5622    Json(result).into_response()
5623}
5624
5625// ── CI ingest endpoint ────────────────────────────────────────────────────────
5626// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
5627// server stores and displays results without cloning or scanning anything itself.
5628//
5629// POST /api/ingest?label=<optional_display_name>
5630// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
5631// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
5632
5633#[derive(Deserialize)]
5634struct IngestQuery {
5635    label: Option<String>,
5636}
5637
5638async fn api_ingest_handler(
5639    State(state): State<AppState>,
5640    Query(q): Query<IngestQuery>,
5641    Json(run): Json<sloc_core::AnalysisRun>,
5642) -> Response {
5643    let label = q.label.unwrap_or_else(|| {
5644        run.input_roots
5645            .first()
5646            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
5647    });
5648
5649    let label_for_task = label.clone();
5650    let result = tokio::task::spawn_blocking(move || {
5651        let html = render_html(&run)?;
5652        let run_id = run.tool.run_id.clone();
5653        let run_id_safe = run_id.len() <= 128
5654            && !run_id.is_empty()
5655            && run_id
5656                .chars()
5657                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
5658        if !run_id_safe {
5659            anyhow::bail!(
5660                "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
5661            );
5662        }
5663        let project_label = sanitize_project_label(&label_for_task);
5664        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
5665        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
5666            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
5667            _ => project_label,
5668        };
5669        let (artifacts, _pending_pdf) = persist_run_artifacts(
5670            &run,
5671            &html,
5672            &output_dir,
5673            true,
5674            true,
5675            false,
5676            &label_for_task,
5677            &file_stem,
5678            RunResultContext::default(),
5679        )?;
5680        Ok::<_, anyhow::Error>((run_id, artifacts, run))
5681    })
5682    .await;
5683
5684    match result {
5685        Ok(Ok((run_id, artifacts, run))) => {
5686            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
5687            (
5688                StatusCode::CREATED,
5689                Json(serde_json::json!({
5690                    "run_id": run_id,
5691                    "view_url": format!("/view-reports?run_id={run_id}"),
5692                })),
5693            )
5694                .into_response()
5695        }
5696        Ok(Err(e)) => (
5697            StatusCode::INTERNAL_SERVER_ERROR,
5698            Json(serde_json::json!({"error": format!("{e:#}")})),
5699        )
5700            .into_response(),
5701        Err(e) => (
5702            StatusCode::INTERNAL_SERVER_ERROR,
5703            Json(serde_json::json!({"error": format!("{e}")})),
5704        )
5705            .into_response(),
5706    }
5707}
5708
5709// ── Trend report page ─────────────────────────────────────────────────────────
5710// Protected. Interactive time-series chart page that loads scan history via
5711// /api/metrics/history and renders a vanilla-SVG line chart.
5712//
5713// GET /trend-reports
5714
5715#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
5716async fn trend_report_handler(
5717    // NOSONAR(rust:S3776)
5718    State(state): State<AppState>,
5719    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5720) -> Response {
5721    auto_scan_watched_dirs(&state).await;
5722
5723    let watched_dirs_list: Vec<String> = {
5724        let wd = state.watched_dirs.lock().await;
5725        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5726    };
5727
5728    // Collect distinct project roots for the root selector dropdown.
5729    let roots: Vec<String> = {
5730        let reg = state.registry.lock().await;
5731        let mut seen = std::collections::BTreeSet::new();
5732        reg.entries
5733            .iter()
5734            .flat_map(|e| e.input_roots.iter().cloned())
5735            .filter(|r| seen.insert(r.clone()))
5736            .collect()
5737    };
5738
5739    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
5740    let nonce = &csp_nonce;
5741    let version = env!("CARGO_PKG_VERSION");
5742
5743    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
5744    let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
5745        r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
5746            .to_string()
5747    } else {
5748        watched_dirs_list
5749            .iter()
5750            .fold(String::new(), |mut s, d| {
5751                use std::fmt::Write as _;
5752                let escaped = d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
5753                write!(
5754                    s,
5755                    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>"#
5756                ).expect("write to String is infallible");
5757                s
5758            })
5759    };
5760    let watched_dirs_html = format!(
5761        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>"#
5762    );
5763
5764    let html = format!(
5765        r##"<!doctype html>
5766<html lang="en">
5767<head>
5768  <meta charset="utf-8" />
5769  <meta name="viewport" content="width=device-width, initial-scale=1" />
5770  <title>OxideSLOC | Trend Reports</title>
5771  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5772  <style nonce="{nonce}">
5773    :root {{
5774      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
5775      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5776      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
5777      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5778      --info-bg:#eef3ff; --info-text:#4467d8;
5779    }}
5780    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
5781    *{{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);}}
5782    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
5783    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
5784    .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;}}
5785    @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));}}}}
5786    .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);}}
5787    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
5788    .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));}}
5789    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
5790    .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;}}
5791    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
5792    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
5793    @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; }} }}
5794    .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;}}
5795    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
5796    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
5797    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
5798    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
5799    .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;}}
5800    .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;}}
5801    .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;}}
5802    .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;}}
5803    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
5804    .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);}}
5805    .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;}}
5806    .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;}}
5807    .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;}}
5808    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
5809    .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;}}
5810    .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);}}
5811    .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;}}
5812    .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;}}
5813    .tz-select:focus{{border-color:var(--oxide);}}
5814    .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
5815    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
5816    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
5817    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
5818    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
5819    .trend-title-block{{flex:1;min-width:0;}}
5820    .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;}}
5821    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
5822    .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;}}
5823    .chart-select:focus{{border-color:var(--accent);}}
5824    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
5825    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
5826    .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;}}
5827    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
5828    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
5829    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
5830    .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);}}
5831    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
5832    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
5833    .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;}}
5834    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
5835    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
5836    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
5837    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
5838    .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;}}
5839    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
5840    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
5841    .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);}}
5842    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
5843    .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;}}
5844    .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;}}
5845    .data-table tr:last-child td{{border-bottom:none;}}
5846    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
5847    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
5848    .table-wrap{{width:100%;overflow-x:auto;}}
5849    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
5850    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
5851    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
5852    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
5853    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
5854    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
5855    .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;}}
5856    .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;}}
5857    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
5858    .pagination-info{{font-size:13px;color:var(--muted);}}
5859    .pagination-btns{{display:flex;gap:6px;}}
5860    .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;}}
5861    .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;}}
5862    #scan-history-table col:nth-child(1){{width:155px;}}
5863    #scan-history-table col:nth-child(2){{width:240px;}}
5864    #scan-history-table col:nth-child(3){{width:82px;}}
5865    #scan-history-table col:nth-child(4){{width:82px;}}
5866    #scan-history-table col:nth-child(5){{width:90px;}}
5867    #scan-history-table col:nth-child(6){{width:90px;}}
5868    #scan-history-table col:nth-child(7){{width:88px;}}
5869    #scan-history-table col:nth-child(8){{width:150px;}}
5870    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
5871    .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;}}
5872    .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;}}
5873    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
5874    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
5875    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
5876    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
5877    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
5878    .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;}}
5879    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
5880    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
5881    .watched-chip-rm:hover{{color:var(--oxide);}}
5882    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
5883    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
5884    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
5885    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
5886    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
5887    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
5888    a.run-link:hover{{text-decoration:underline;}}
5889    .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);}}
5890    .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);}}
5891    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
5892    .metric-num{{font-weight:700;color:var(--text);}}
5893    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
5894    .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;}}
5895    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
5896    .btn.primary:hover{{opacity:.9;}}
5897    .rpt-btn{{min-width:58px;justify-content:center;}}
5898    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
5899    .report-cell{{overflow:visible!important;white-space:normal!important;}}
5900    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
5901    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
5902    .submod-details summary::-webkit-details-marker{{display:none;}}
5903    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
5904    .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;}}
5905    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
5906    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
5907    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
5908    .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;}}
5909    .export-btn:hover{{background:var(--line);}}
5910    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
5911    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
5912    .site-footer a{{color:var(--muted);}}
5913    .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;}}
5914    .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;}}
5915    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
5916  </style>
5917</head>
5918<body>
5919  <div class="background-watermarks" aria-hidden="true">
5920    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5921    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5922    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5923    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5924    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5925    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5926  </div>
5927  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5928  <div class="top-nav">
5929    <div class="top-nav-inner">
5930      <a class="brand" href="/">
5931        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5932        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
5933      </a>
5934      <div class="nav-right">
5935        <a class="nav-pill" href="/">Home</a>
5936        <div class="nav-dropdown">
5937          <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>
5938          <div class="nav-dropdown-menu">
5939            <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>
5940          </div>
5941        </div>
5942        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5943        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
5944        <div class="nav-dropdown">
5945          <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>
5946          <div class="nav-dropdown-menu">
5947            <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>
5948          </div>
5949        </div>
5950        <div class="server-status-wrap">
5951          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
5952          <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>
5953        </div>
5954        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5955          <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>
5956        </button>
5957        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5958          <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>
5959          <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>
5960        </button>
5961      </div>
5962    </div>
5963  </div>
5964
5965  <div class="page">
5966    {watched_dirs_html}
5967    <div class="summary-strip" id="trend-stats"></div>
5968    <div class="panel">
5969      <div class="trend-header">
5970        <div class="trend-title-block">
5971          <h1>Trend Reports</h1>
5972          <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>
5973          <span class="chart-hint-inline">
5974            <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>
5975            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
5976          </span>
5977        </div>
5978        <div class="chart-actions">
5979          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
5980            <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>
5981            Export Excel
5982          </button>
5983          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
5984            <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>
5985            Export PNG
5986          </button>
5987        </div>
5988      </div>
5989
5990      <div class="controls-centered">
5991        <label>Project Root:
5992          <select class="chart-select" id="root-sel">
5993            <option value="">All projects</option>
5994          </select>
5995        </label>
5996        <label>Y Metric:
5997          <select class="chart-select" id="y-sel">
5998            <option value="code_lines">Code Lines</option>
5999            <option value="comment_lines">Comment Lines</option>
6000            <option value="blank_lines">Blank Lines</option>
6001            <option value="physical_lines">Physical Lines</option>
6002            <option value="files_analyzed">Files Analyzed</option>
6003          </select>
6004        </label>
6005        <label>X Axis:
6006          <select class="chart-select" id="x-sel">
6007            <option value="time">By Time</option>
6008            <option value="commit">By Commit</option>
6009            <option value="release">By Release</option>
6010            <option value="tag">Tagged Commits</option>
6011          </select>
6012        </label>
6013        <label id="submodule-label" style="display:none;">Submodule:
6014          <select class="chart-select" id="sub-sel">
6015            <option value="">All (project total)</option>
6016          </select>
6017        </label>
6018        <label>Chart Size:
6019          <select class="chart-select" id="scale-sel">
6020            <option value="0.75">Compact</option>
6021            <option value="1.2" selected>Normal</option>
6022            <option value="1.38">Large</option>
6023          </select>
6024        </label>
6025      </div>
6026
6027      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
6028      <div id="data-table-wrap" style="overflow-x:auto;"></div>
6029    </div>
6030  </div>
6031
6032  <script nonce="{nonce}">
6033    (function() {{
6034      // Theme persistence
6035      var b = document.body;
6036      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
6037      var tgl = document.getElementById('theme-toggle');
6038      if (tgl) tgl.addEventListener('click', function() {{
6039        var d = b.classList.toggle('dark-theme');
6040        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
6041      }});
6042
6043      // Watermark randomizer
6044      (function() {{
6045        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6046        if (!wms.length) return;
6047        var placed = [];
6048        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;}}
6049        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];}}
6050        var half=Math.floor(wms.length/2);
6051        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;}});
6052      }})();
6053
6054      // Code particles
6055      (function() {{
6056        var container = document.getElementById('code-particles');
6057        if (!container) return;
6058        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'];
6059        for (var i = 0; i < 38; i++) {{
6060          (function(idx) {{
6061            var el = document.createElement('span');
6062            el.className = 'code-particle';
6063            el.textContent = snippets[idx % snippets.length];
6064            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
6065            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
6066            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
6067            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';
6068            container.appendChild(el);
6069          }})(i);
6070        }}
6071      }})();
6072
6073      // Watched folder picker
6074      (function() {{
6075        var btn = document.getElementById('add-watched-btn');
6076        if (!btn) return;
6077        btn.addEventListener('click', function() {{
6078          fetch('/pick-directory?kind=reports')
6079            .then(function(r) {{ return r.json(); }})
6080            .then(function(data) {{
6081              if (!data.cancelled && data.selected_path) {{
6082                var form = document.createElement('form');
6083                form.method = 'POST';
6084                form.action = '/watched-dirs/add';
6085                var ri = document.createElement('input');
6086                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
6087                var fi = document.createElement('input');
6088                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
6089                form.appendChild(ri); form.appendChild(fi);
6090                document.body.appendChild(form);
6091                form.submit();
6092              }}
6093            }})
6094            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
6095        }});
6096      }})();
6097
6098      // Settings / color-scheme modal
6099      (function() {{
6100        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'}}];
6101        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);}});}}
6102        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
6103        var btn=document.getElementById('settings-btn');if(!btn)return;
6104        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
6105        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>';
6106        document.body.appendChild(m);
6107        var g=document.getElementById('scheme-grid');
6108        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);}});
6109        var cl=document.getElementById('settings-close');
6110        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);
6111        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');}});
6112        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
6113        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
6114      }})();
6115    }})();
6116
6117    var ROOTS = {roots_json};
6118    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
6119    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
6120    var allData = [];
6121
6122    // Populate root selector
6123    var rootSel = document.getElementById('root-sel');
6124    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
6125
6126    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();}}
6127    function fmtFull(n){{return Number(n).toLocaleString();}}
6128    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
6129
6130    // Tooltip
6131    var tt = document.createElement('div');
6132    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);';
6133    document.body.appendChild(tt);
6134    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
6135    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';}}
6136    function hideTT(){{tt.style.display='none';}}
6137
6138    function statExact(compact, full){{
6139      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
6140    }}
6141    function statVal(n){{
6142      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
6143    }}
6144
6145    function updateStats(data){{
6146      var statsEl=document.getElementById('trend-stats');
6147      if(!statsEl)return;
6148      if(!data||!data.length){{statsEl.innerHTML='';return;}}
6149      var yKey=document.getElementById('y-sel').value;
6150      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
6151      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6152      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
6153      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
6154      var absDelta=Math.abs(delta);
6155      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
6156      var deltaExact=statExact(deltaCompact,deltaFull);
6157      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
6158      statsEl.innerHTML=
6159        '<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>'+
6160        '<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>'+
6161        '<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>'+
6162        '<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>';
6163    }}
6164
6165    var subSel = document.getElementById('sub-sel');
6166    var subLabel = document.getElementById('submodule-label');
6167
6168    function populateSubmodules(root){{
6169      if(!subSel||!subLabel)return;
6170      while(subSel.options.length>1)subSel.remove(1);
6171      subSel.value='';
6172      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
6173      fetch(url)
6174        .then(function(r){{return r.json();}})
6175        .then(function(subs){{
6176          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
6177          subs.forEach(function(s){{
6178            var o=document.createElement('option');
6179            o.value=s.name;
6180            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
6181            subSel.appendChild(o);
6182          }});
6183          subLabel.style.display='';
6184        }})
6185        .catch(function(){{subLabel.style.display='none';}});
6186    }}
6187
6188    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div>';
6189
6190    function loadAndRender(){{
6191      var root = rootSel.value;
6192      var sub = subSel ? subSel.value : '';
6193      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
6194      document.getElementById('data-table-wrap').innerHTML='';
6195      var url = '/api/metrics/history?limit=100'
6196        + (root ? '&root='+encodeURIComponent(root) : '')
6197        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
6198      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
6199        allData = data;
6200        render(data);
6201        updateStats(data);
6202      }}).catch(function(){{
6203        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>';
6204      }});
6205    }}
6206
6207    function render(data){{
6208      var yKey = document.getElementById('y-sel').value;
6209      var xMode = document.getElementById('x-sel').value;
6210
6211      // Filter for tag/release mode
6212      var pts = data;
6213      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
6214
6215      // Sort oldest-first for the line chart
6216      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6217
6218      var wrap = document.getElementById('chart-wrap');
6219      if(!pts.length){{
6220        var emptyMsg = (xMode === 'tag')
6221          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
6222          : 'No scan data found for the selected filters.';
6223        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
6224        renderTable([]);
6225        return;
6226      }}
6227
6228      var scaleEl=document.getElementById('scale-sel');
6229      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
6230      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;
6231      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
6232
6233      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
6234
6235      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">';
6236      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>';
6237
6238      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
6239
6240      // Grid + Y axis ticks
6241      for(var ti=0;ti<=5;ti++){{
6242        var gy=PT+CH-Math.round(ti/5*CH);
6243        var gv=Math.round(ti/5*maxY);
6244        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
6245        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
6246      }}
6247
6248      // X axis labels (every N-th point to avoid crowding)
6249      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
6250      pts.forEach(function(d,i){{
6251        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6252        if(i%labelEvery===0||i===pts.length-1){{
6253          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)));
6254          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>';
6255        }}
6256      }});
6257
6258      // Axis label
6259      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
6260      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>';
6261      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>';
6262
6263      // Area fill + line path
6264      var pathD='';
6265      pts.forEach(function(d,i){{
6266        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6267        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6268        pathD+=(i===0?'M':'L')+x+','+y;
6269      }});
6270      if(pts.length>1){{
6271        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
6272        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
6273      }}
6274      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
6275
6276      // Data points (clickable) + permanent value labels
6277      var showLabels = pts.length <= 40;
6278      var labelEveryN = pts.length > 20 ? 2 : 1;
6279      pts.forEach(function(d,i){{
6280        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6281        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6282        var hasTags=d.tags&&d.tags.length>0;
6283        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
6284        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
6285        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+'"/>';
6286        if(showLabels && i%labelEveryN===0){{
6287          var lx=x, ly=y-r-5;
6288          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>';
6289        }}
6290      }});
6291
6292      svg+='</svg>';
6293      wrap.innerHTML=svg;
6294
6295      // Attach point tooltips
6296      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
6297        c.addEventListener('mouseover',function(e){{
6298          var d=pts[parseInt(this.dataset.idx)];
6299          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(''):'';
6300          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>':'';
6301          showTT(e,
6302            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
6303            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
6304            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
6305            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
6306          );
6307          this.setAttribute('r','8');
6308        }});
6309        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
6310        c.addEventListener('mousemove',moveTT);
6311        c.addEventListener('click',function(){{
6312          var d=pts[parseInt(this.dataset.idx)];
6313          if(d.html_url) window.open(d.html_url,'_blank');
6314        }});
6315      }});
6316
6317      renderTable(pts, yKey);
6318    }}
6319
6320    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
6321    var shProjFilter='', shBranchFilter='';
6322
6323    function fmtPST(isoStr){{
6324      if(!isoStr)return'';
6325      var d=new Date(isoStr);
6326      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
6327      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);}}
6328      function p(n){{return n<10?'0'+n:String(n);}}
6329      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++;}}}}
6330      var yr=d.getUTCFullYear();
6331      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
6332      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
6333      var isDST=d>=dstStart&&d<dstEnd;
6334      var off=isDST?-7*3600*1000:-8*3600*1000;
6335      var lbl=isDST?'PDT':'PST';
6336      var loc=new Date(d.getTime()+off);
6337      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
6338    }}
6339
6340    function getShRows(){{
6341      var proj=shProjFilter.toLowerCase().trim();
6342      var branch=shBranchFilter;
6343      return shData.filter(function(d){{
6344        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
6345        if(branch&&(d.branch||'')!==branch)return false;
6346        return true;
6347      }});
6348    }}
6349
6350    function renderShPage(){{
6351      var filtered=getShRows();
6352      if(shSortCol){{
6353        filtered.sort(function(a,b){{
6354          var va,vb;
6355          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
6356          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
6357          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
6358          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
6359          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
6360          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
6361        }});
6362      }}
6363      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
6364      shPage=Math.min(shPage,totalPages);
6365      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
6366      var visible=filtered.slice(start,end);
6367      var tbody=document.getElementById('sh-tbody');
6368      if(!tbody)return;
6369      tbody.innerHTML=visible.map(function(d){{
6370        var tsHtml=esc(fmtPST(d.timestamp));
6371        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>';
6372        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>';
6373        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
6374        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
6375        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
6376        var reportCell='';
6377        if(d.html_url){{
6378          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
6379          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>';}}
6380          reportCell+='</div>';
6381        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
6382        if(d.submodule_links&&d.submodule_links.length){{
6383          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
6384          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
6385          reportCell+='</div></details>';
6386        }}
6387        return '<tr>'
6388          +'<td>'+tsHtml+'</td>'
6389          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
6390          +'<td>'+runIdHtml+'</td>'
6391          +'<td>'+commitHtml+'</td>'
6392          +'<td>'+branchHtml+'</td>'
6393          +'<td>'+tags+'</td>'
6394          +'<td class="num">'+metricHtml+'</td>'
6395          +'<td class="report-cell">'+reportCell+'</td>'
6396          +'</tr>';
6397      }}).join('');
6398      var pgRange=document.getElementById('sh-pg-range');
6399      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'–'+end+' of '+total:'No results';
6400      var pgInfo=document.getElementById('sh-pg-info');
6401      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
6402      var pgBtns=document.getElementById('sh-pg-btns');
6403      if(pgBtns){{
6404        pgBtns.innerHTML='';
6405        function mkPgBtn(lbl,pg,active,disabled){{
6406          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
6407          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
6408          return b;
6409        }}
6410        pgBtns.appendChild(mkPgBtn('‹',shPage-1,false,shPage===1));
6411        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
6412        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
6413        pgBtns.appendChild(mkPgBtn('›',shPage+1,false,shPage===totalPages));
6414      }}
6415    }}
6416
6417    function wireTableBehavior(){{
6418      var pf=document.getElementById('sh-proj-filter');
6419      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
6420      var bf=document.getElementById('sh-branch-filter');
6421      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
6422      var rb=document.getElementById('sh-reset-btn');
6423      if(rb)rb.addEventListener('click',function(){{
6424        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
6425        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
6426        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
6427        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');}});
6428        renderShPage();
6429      }});
6430      var pps=document.getElementById('sh-per-page');
6431      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
6432      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
6433      ths.forEach(function(th){{
6434        th.addEventListener('click',function(e){{
6435          if(e.target.classList.contains('col-resize-handle'))return;
6436          var col=th.dataset.col;
6437          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
6438          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
6439          th.classList.add('sort-'+shSortOrder);
6440          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
6441          shPage=1;renderShPage();
6442        }});
6443      }});
6444      var table=document.getElementById('scan-history-table');
6445      if(!table)return;
6446      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
6447      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
6448      allThs.forEach(function(th,i){{
6449        var handle=th.querySelector('.col-resize-handle');
6450        if(!handle||!cols[i])return;
6451        var startX,startW;
6452        handle.addEventListener('mousedown',function(e){{
6453          e.stopPropagation();e.preventDefault();
6454          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
6455          handle.classList.add('dragging');
6456          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
6457          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
6458          document.addEventListener('mousemove',onMove);
6459          document.addEventListener('mouseup',onUp);
6460        }});
6461      }});
6462    }}
6463
6464    function renderTable(pts, yKey){{
6465      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
6466      var wrap=document.getElementById('data-table-wrap');
6467      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
6468      var yLabel=Y_LABELS[yKey]||yKey||'';
6469      shData=pts.slice().reverse();
6470      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
6471      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
6472      var branches={{}};
6473      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
6474      var branchOpts='<option value="">All branches</option>';
6475      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
6476      wrap.innerHTML=
6477        '<div class="chart-section-header">SCAN HISTORY</div>'+
6478        '<div class="filter-row">'+
6479          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project…">'+
6480          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
6481          '<button type="button" class="btn" id="sh-reset-btn">↻ Reset view</button>'+
6482        '</div>'+
6483        '<div class="table-wrap">'+
6484        '<table id="scan-history-table" class="data-table">'+
6485        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
6486        '<thead><tr id="sh-thead">'+
6487        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6488        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6489        '<th>Run ID<div class="col-resize-handle"></div></th>'+
6490        '<th>Commit<div class="col-resize-handle"></div></th>'+
6491        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6492        '<th>Tags<div class="col-resize-handle"></div></th>'+
6493        '<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>'+
6494        '<th>Report<div class="col-resize-handle"></div></th>'+
6495        '</tr></thead>'+
6496        '<tbody id="sh-tbody"></tbody>'+
6497        '</table>'+
6498        '</div>'+
6499        '<div class="pagination">'+
6500          '<span class="pagination-info" id="sh-pg-info"></span>'+
6501          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
6502          '<div style="display:flex;align-items:center;gap:8px;">'+
6503            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
6504            '<select class="filter-select" id="sh-per-page">'+
6505              '<option value="10">10 per page</option>'+
6506              '<option value="25" selected>25 per page</option>'+
6507              '<option value="50">50 per page</option>'+
6508              '<option value="100">100 per page</option>'+
6509            '</select>'+
6510            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
6511          '</div>'+
6512        '</div>';
6513      wireTableBehavior();
6514      renderShPage();
6515    }}
6516
6517    function exportXLSX(){{
6518      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
6519      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
6520      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
6521      var s1R=sorted.map(function(d){{
6522        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||''];
6523      }});
6524      var pm={{}};
6525      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
6526      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'];
6527      var s2R=Object.keys(pm).map(function(p){{
6528        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6529        var lat=sc[sc.length-1],fst=sc[0];
6530        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
6531        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);
6532        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];
6533      }});
6534      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
6535      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
6536      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
6537      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
6538    }}
6539
6540    function buildXLSX(sheets,chartRows,chartRows2){{
6541      function s2b(s){{return new TextEncoder().encode(s);}}
6542      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
6543      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;}}
6544      function crc32(d){{
6545        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;}}}}
6546        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
6547      }}
6548      function buildSheet(hdr,rows,drawRid,withCtrl){{
6549        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
6550        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
6551        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
6552        x+='<row r="1">';
6553        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
6554        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>';}}
6555        x+='</row>';
6556        rows.forEach(function(row,ri){{
6557          var rn=ri+2;
6558          x+='<row r="'+rn+'">';
6559          row.forEach(function(cell,ci){{
6560            var addr=col2l(ci+1)+rn;
6561            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
6562            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
6563          }});
6564          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>';}}
6565          x+='</row>';
6566        }});
6567        x+='</sheetData>';
6568        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>';}}
6569        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
6570        return x+'</worksheet>';
6571      }}
6572      function buildChartXML(rows){{
6573        var sn="'Scan History'";
6574        var nr=rows.length,er=nr+1;
6575        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'}}];
6576        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6577        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">';
6578        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6579        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6580        sd.forEach(function(s,i){{
6581          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6582          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>';
6583          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6584          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>';
6585          var dlp=(i===2)?'b':'t';
6586          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>';
6587          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6588          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6589          x+='</c:strCache></c:strRef></c:cat>';
6590          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+'"/>';
6591          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6592          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6593        }});
6594        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
6595        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>';
6596        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>';
6597        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6598        return x;
6599      }}
6600      function buildChartXML2(rows){{
6601        var sn="'By Project'";
6602        var nr=rows.length,er=nr+1;
6603        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'}}];
6604        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6605        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">';
6606        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6607        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6608        sd.forEach(function(s,i){{
6609          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6610          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>';
6611          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6612          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>';
6613          var dlp=(i===2)?'b':'t';
6614          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>';
6615          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6616          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6617          x+='</c:strCache></c:strRef></c:cat>';
6618          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+'"/>';
6619          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6620          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6621        }});
6622        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
6623        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>';
6624        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>';
6625        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6626        return x;
6627      }}
6628      function buildChartXML3(rows){{
6629        var sn="'Scan History'";
6630        var nr=rows.length,er=nr+1;
6631        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6632        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">';
6633        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
6634        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6635        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
6636        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>';
6637        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
6638        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>';
6639        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>';
6640        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6641        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6642        x+='</c:strCache></c:strRef></c:cat>';
6643        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+'"/>';
6644        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
6645        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6646        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
6647        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>';
6648        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>';
6649        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>';
6650        return x;
6651      }}
6652      var hasChart=!!(chartRows&&chartRows.length);
6653      var nr=hasChart?chartRows.length:0;
6654      var hasChart2=!!(chartRows2&&chartRows2.length);
6655      var nr2=hasChart2?chartRows2.length:0;
6656      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>';
6657      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"/>';
6658      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
6659      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"/>';}}
6660      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"/>';}}
6661      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
6662      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>';
6663      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
6664      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"/>';}});
6665      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
6666      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>';
6667      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
6668      wbx+='</sheets></workbook>';
6669      var files=[
6670        {{name:'[Content_Types].xml',data:s2b(ct)}},
6671        {{name:'_rels/.rels',data:s2b(dotrels)}},
6672        {{name:'xl/workbook.xml',data:s2b(wbx)}},
6673        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
6674        {{name:'xl/styles.xml',data:s2b(styl)}}
6675      ];
6676      // Chart embedded directly in Scan History (sheet1); By Project is plain
6677      sheets.forEach(function(s,i){{
6678        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)))}});
6679      }});
6680      if(hasChart){{
6681        var fromRow=nr+4,toRow=nr+24;
6682        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>')}});
6683        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6684        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">';
6685        drx+='<xdr:twoCellAnchor editAs="twoCell">';
6686        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>';
6687        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>';
6688        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6689        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6690        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6691        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6692        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
6693        var focRow=toRow+2,focRowEnd=toRow+22;
6694        drx+='<xdr:twoCellAnchor editAs="twoCell">';
6695        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>';
6696        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>';
6697        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6698        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6699        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6700        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
6701        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
6702        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
6703        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>')}});
6704        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
6705        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
6706      }}
6707      if(hasChart2){{
6708        var fromRow2=nr2+4,toRow2=nr2+24;
6709        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>')}});
6710        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6711        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">';
6712        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
6713        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>';
6714        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>';
6715        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6716        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6717        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6718        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6719        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
6720        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
6721        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>')}});
6722        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
6723      }}
6724      var parts=[],offsets=[],total=0;
6725      files.forEach(function(f){{
6726        offsets.push(total);
6727        var nb=s2b(f.name),crc=crc32(f.data);
6728        var h=new DataView(new ArrayBuffer(30+nb.length));
6729        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
6730        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
6731        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
6732        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
6733        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
6734        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
6735        total+=30+nb.length+f.data.length;
6736      }});
6737      var cdStart=total;
6738      files.forEach(function(f,fi){{
6739        var nb=s2b(f.name),crc=crc32(f.data);
6740        var cd=new DataView(new ArrayBuffer(46+nb.length));
6741        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
6742        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
6743        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
6744        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
6745        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
6746        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
6747        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
6748      }});
6749      var cdSz=total-cdStart;
6750      var eocd=new DataView(new ArrayBuffer(22));
6751      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
6752      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
6753      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
6754      parts.push(new Uint8Array(eocd.buffer));
6755      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
6756      var out=new Uint8Array(sz);var off=0;
6757      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
6758      return out.buffer;
6759    }}
6760
6761    function exportPNG(){{
6762      var svgEl=document.querySelector('#chart-wrap svg');
6763      if(!svgEl){{alert('No chart to export yet.');return;}}
6764      var svgStr=new XMLSerializer().serializeToString(svgEl);
6765      var vb=svgEl.viewBox.baseVal,scale=2;
6766      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
6767      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
6768      var url=URL.createObjectURL(blob);
6769      var img=new Image();
6770      img.onload=function(){{
6771        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
6772        var ctx=canvas.getContext('2d');
6773        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
6774        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
6775        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
6776        URL.revokeObjectURL(url);
6777        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
6778      }};
6779      img.src=url;
6780    }}
6781
6782    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
6783      var el=document.getElementById(id);
6784      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
6785    }});
6786    rootSel.addEventListener('change',function(){{
6787      populateSubmodules(rootSel.value);
6788      loadAndRender();
6789    }});
6790    if(subSel)subSel.addEventListener('change',loadAndRender);
6791
6792    var xlsxBtn=document.getElementById('export-xlsx-btn');
6793    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
6794    var pngBtn=document.getElementById('export-png-btn');
6795    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
6796
6797    populateSubmodules(rootSel.value);
6798    loadAndRender();
6799
6800    (function randomizeWatermarks() {{
6801      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6802      if (!wms.length) return;
6803      var placed = [];
6804      function tooClose(top, left) {{
6805        for (var i = 0; i < placed.length; i++) {{
6806          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
6807          if (dt < 16 && dl < 12) return true;
6808        }}
6809        return false;
6810      }}
6811      function pick(leftBand) {{
6812        for (var attempt = 0; attempt < 50; attempt++) {{
6813          var top = Math.random() * 88 + 2;
6814          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6815          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
6816        }}
6817        var top = Math.random() * 88 + 2;
6818        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6819        placed.push([top, left]); return [top, left];
6820      }}
6821      var half = Math.floor(wms.length / 2);
6822      wms.forEach(function (img, i) {{
6823        var pos = pick(i < half);
6824        var size = Math.floor(Math.random() * 100 + 120);
6825        var rot = (Math.random() * 360).toFixed(1);
6826        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
6827        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;
6828      }});
6829    }})();
6830    (function spawnCodeParticles() {{
6831      var container = document.getElementById('code-particles');
6832      if (!container) return;
6833      var snippets = [
6834        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
6835        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
6836        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
6837        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
6838        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
6839      ];
6840      var count = 38;
6841      for (var i = 0; i < count; i++) {{
6842        (function(idx) {{
6843          var el = document.createElement('span');
6844          el.className = 'code-particle';
6845          el.textContent = snippets[idx % snippets.length];
6846          var left = Math.random() * 94 + 2;
6847          var top = Math.random() * 88 + 6;
6848          var dur = (Math.random() * 10 + 9).toFixed(1);
6849          var delay = (Math.random() * 18).toFixed(1);
6850          var rot = (Math.random() * 26 - 13).toFixed(1);
6851          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6852          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
6853          container.appendChild(el);
6854        }})(i);
6855      }}
6856    }})();
6857  </script>
6858  <footer class="site-footer">
6859    oxide-sloc v{version} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
6860    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6861    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6862    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6863    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
6864  </footer>
6865</body>
6866</html>"##,
6867    );
6868
6869    Html(html).into_response()
6870}
6871
6872#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
6873#[allow(clippy::too_many_lines)] // JSON data builder for test-metrics scope; splitting would scatter related fields
6874fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
6875    // NOSONAR(rust:S3776)
6876    use std::collections::HashMap;
6877    let mut langs: Vec<&sloc_core::LanguageSummary> = run
6878        .totals_by_language
6879        .iter()
6880        .filter(|l| l.test_count > 0)
6881        .collect();
6882    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6883    let lang_tests: Vec<serde_json::Value> = langs
6884        .iter()
6885        .map(|l| {
6886            let d = if l.code_lines > 0 {
6887                l.test_count as f64 / l.code_lines as f64 * 1000.0
6888            } else {
6889                0.0
6890            };
6891            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6892                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6893                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6894        })
6895        .collect();
6896    let has_file_cov = run.per_file_records.iter().any(|f| f.coverage.is_some());
6897    let cov_arr: Vec<serde_json::Value> = if has_file_cov {
6898        let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
6899        for rec in &run.per_file_records {
6900            if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
6901                let e = totals.entry(lang.display_name().to_string()).or_default();
6902                e.0 += u64::from(cov.lines_found);
6903                e.1 += u64::from(cov.lines_hit);
6904            }
6905        }
6906        let mut pairs: Vec<(String, f64)> = totals
6907            .into_iter()
6908            .filter(|(_, (found, _))| *found > 0)
6909            .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
6910            .collect();
6911        pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6912        pairs
6913            .iter()
6914            .map(
6915                |(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}),
6916            )
6917            .collect()
6918    } else {
6919        vec![]
6920    };
6921    let (mut high, mut mid, mut low) = (0u64, 0u64, 0u64);
6922    for rec in &run.per_file_records {
6923        if let Some(cov) = &rec.coverage {
6924            if cov.lines_found == 0 {
6925                continue;
6926            }
6927            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
6928            if pct >= 80.0 {
6929                high += 1;
6930            } else if pct >= 50.0 {
6931                mid += 1;
6932            } else {
6933                low += 1;
6934            }
6935        }
6936    }
6937    let t = &run.summary_totals;
6938    let total_tests = t.test_count;
6939    let density = if t.code_lines > 0 {
6940        total_tests as f64 / t.code_lines as f64 * 1000.0
6941    } else {
6942        0.0
6943    };
6944    let most_tested = langs.first().map_or_else(
6945        || "\u{2014}".to_string(),
6946        |l| l.language.display_name().to_string(),
6947    );
6948    let test_files: u64 = run
6949        .per_file_records
6950        .iter()
6951        .filter(|f| f.raw_line_categories.test_count > 0)
6952        .count() as u64;
6953    let cov_line = if t.coverage_lines_found > 0 {
6954        format!(
6955            "{:.1}",
6956            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
6957        )
6958    } else {
6959        "0".to_string()
6960    };
6961    let cov_fn = if t.coverage_functions_found > 0 {
6962        format!(
6963            "{:.1}",
6964            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
6965        )
6966    } else {
6967        "0".to_string()
6968    };
6969    let cov_branch = if t.coverage_branches_found > 0 {
6970        format!(
6971            "{:.1}",
6972            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
6973        )
6974    } else {
6975        "0".to_string()
6976    };
6977    let has_cov = !cov_arr.is_empty();
6978    let mut file_cov_arr: Vec<serde_json::Value> = run
6979        .per_file_records
6980        .iter()
6981        .filter_map(|rec| {
6982            rec.coverage.as_ref().map(|cov| {
6983                let line_pct = if cov.lines_found > 0 {
6984                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
6985                        / 10.0
6986                } else {
6987                    0.0
6988                };
6989                let fn_pct = if cov.functions_found > 0 {
6990                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
6991                        .round()
6992                        / 10.0
6993                } else {
6994                    -1.0
6995                };
6996                serde_json::json!({
6997                    "rel": rec.relative_path,
6998                    "lang": rec.language.map_or("?", |l| l.display_name()),
6999                    "line_pct": line_pct,
7000                    "fn_pct": fn_pct,
7001                    "lhit": cov.lines_hit,
7002                    "lfound": cov.lines_found,
7003                    "fhit": cov.functions_hit,
7004                    "ffound": cov.functions_found,
7005                })
7006            })
7007        })
7008        .collect();
7009    file_cov_arr.sort_by(|a, b| {
7010        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
7011        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
7012        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
7013    });
7014    serde_json::json!({
7015        "totals": {
7016            "test_count": total_tests,
7017            "assertions": t.test_assertion_count,
7018            "suites": t.test_suite_count,
7019            "test_files": test_files,
7020            "total_files": t.files_analyzed,
7021            "density_str": format!("{density:.1}"),
7022            "most_tested": most_tested,
7023            "langs_with_tests": langs.len(),
7024            "cov_line": cov_line,
7025            "cov_fn": cov_fn,
7026            "cov_branch": cov_branch,
7027        },
7028        "lang_tests": lang_tests,
7029        "cov": cov_arr,
7030        "cov_tiers": {"high": high, "mid": mid, "low": low},
7031        "file_cov": file_cov_arr,
7032        "has_coverage": has_cov,
7033        "submodules": {},
7034    })
7035}
7036
7037#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
7038fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
7039    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
7040        .language_summaries
7041        .iter()
7042        .filter(|l| l.test_count > 0)
7043        .collect();
7044    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
7045    let lang_tests: Vec<serde_json::Value> = langs
7046        .iter()
7047        .map(|l| {
7048            let d = if l.code_lines > 0 {
7049                l.test_count as f64 / l.code_lines as f64 * 1000.0
7050            } else {
7051                0.0
7052            };
7053            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
7054                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
7055                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
7056        })
7057        .collect();
7058    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
7059    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
7060    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
7061    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
7062    let density = if sub.code_lines > 0 {
7063        total_tests as f64 / sub.code_lines as f64 * 1000.0
7064    } else {
7065        0.0
7066    };
7067    let most_tested = langs.first().map_or_else(
7068        || "\u{2014}".to_string(),
7069        |l| l.language.display_name().to_string(),
7070    );
7071    serde_json::json!({
7072        "totals": {
7073            "test_count": total_tests,
7074            "assertions": total_assertions,
7075            "suites": total_suites,
7076            "test_files": test_files_approx,
7077            "total_files": sub.files_analyzed,
7078            "density_str": format!("{density:.1}"),
7079            "most_tested": most_tested,
7080            "langs_with_tests": langs.len(),
7081            "cov_line": "0",
7082            "cov_fn": "0",
7083            "cov_branch": "0",
7084        },
7085        "lang_tests": lang_tests,
7086        "cov": [],
7087        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
7088        "has_coverage": false,
7089    })
7090}
7091
7092// GET /test-metrics
7093#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
7094#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
7095async fn test_metrics_handler(
7096    // NOSONAR(rust:S3776)
7097    State(state): State<AppState>,
7098    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7099) -> Response {
7100    auto_scan_watched_dirs(&state).await;
7101    let watched_dirs_list: Vec<String> = {
7102        let wd = state.watched_dirs.lock().await;
7103        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7104    };
7105    let latest_run: Option<AnalysisRun> = {
7106        let reg = state.registry.lock().await;
7107        let json_str: Option<String> = reg
7108            .entries
7109            .first()
7110            .and_then(|e| e.json_path.as_ref())
7111            .and_then(|p| std::fs::read_to_string(p).ok());
7112        drop(reg);
7113        json_str
7114            .as_deref()
7115            .and_then(|s| serde_json::from_str(s).ok())
7116    };
7117
7118    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
7119    let _lang_tests_json: String = latest_run.as_ref().map_or_else(
7120        || "[]".to_string(),
7121        |r| {
7122            let mut langs: Vec<&sloc_core::LanguageSummary> = r
7123                .totals_by_language
7124                .iter()
7125                .filter(|l| l.test_count > 0)
7126                .collect();
7127            langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
7128            let parts: Vec<String> = langs
7129                .iter()
7130                .map(|l| {
7131                    let name = l.language.display_name().replace('"', "\\\"");
7132                    let density = if l.code_lines > 0 {
7133                        // ratio for density display, precision loss acceptable
7134                        #[allow(clippy::cast_precision_loss)]
7135                        { l.test_count as f64 / l.code_lines as f64 * 1000.0 }
7136                    } else {
7137                        0.0
7138                    };
7139                    format!(
7140                        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
7141                        name = name,
7142                        t = l.test_count,
7143                        a = l.test_assertion_count,
7144                        s = l.test_suite_count,
7145                        c = l.code_lines,
7146                        d = density,
7147                        f = l.files,
7148                    )
7149                })
7150                .collect();
7151            format!("[{}]", parts.join(","))
7152        },
7153    );
7154
7155    // Build coverage chart JSON (per-language avg line coverage %).
7156    let cov_json: String = match &latest_run {
7157        Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
7158            use std::collections::HashMap;
7159            let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
7160            for rec in &r.per_file_records {
7161                if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
7162                    let e = totals.entry(lang.display_name().to_string()).or_default();
7163                    e.0 += u64::from(cov.lines_found);
7164                    e.1 += u64::from(cov.lines_hit);
7165                }
7166            }
7167            let mut pairs: Vec<(String, f64)> = totals
7168                .into_iter()
7169                .filter(|(_, (found, _))| *found > 0)
7170                .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
7171                .collect();
7172            pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7173            let parts: Vec<String> = pairs
7174                .iter()
7175                .map(|(lang, pct)| {
7176                    let name = lang.replace('"', "\\\"");
7177                    format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
7178                })
7179                .collect();
7180            format!("[{}]", parts.join(","))
7181        }
7182        _ => "[]".to_string(),
7183    };
7184
7185    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
7186    let _cov_tier_json: String = match &latest_run {
7187        Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
7188            let mut high = 0u64; // >= 80%
7189            let mut mid = 0u64; // 50-79%
7190            let mut low = 0u64; // < 50%
7191            for rec in &r.per_file_records {
7192                if let Some(cov) = &rec.coverage {
7193                    if cov.lines_found == 0 {
7194                        continue;
7195                    }
7196                    let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
7197                    if pct >= 80.0 {
7198                        high += 1;
7199                    } else if pct >= 50.0 {
7200                        mid += 1;
7201                    } else {
7202                        low += 1;
7203                    }
7204                }
7205            }
7206            format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
7207        }
7208        _ => r#"{"high":0,"mid":0,"low":0}"#.to_string(),
7209    };
7210
7211    let total_tests: u64 = latest_run
7212        .as_ref()
7213        .map_or(0, |r| r.summary_totals.test_count);
7214    let total_assertions: u64 = latest_run
7215        .as_ref()
7216        .map_or(0, |r| r.summary_totals.test_assertion_count);
7217    let total_suites: u64 = latest_run
7218        .as_ref()
7219        .map_or(0, |r| r.summary_totals.test_suite_count);
7220    let total_code: u64 = latest_run
7221        .as_ref()
7222        .map_or(0, |r| r.summary_totals.code_lines);
7223    let workspace_density: f64 = if total_code > 0 {
7224        total_tests as f64 / total_code as f64 * 1000.0
7225    } else {
7226        0.0
7227    };
7228    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
7229        r.totals_by_language
7230            .iter()
7231            .filter(|l| l.test_count > 0)
7232            .count()
7233    });
7234    let most_tested: String = latest_run
7235        .as_ref()
7236        .and_then(|r| {
7237            r.totals_by_language
7238                .iter()
7239                .filter(|l| l.test_count > 0)
7240                .max_by_key(|l| l.test_count)
7241        })
7242        .map_or_else(
7243            || "\u{2014}".to_string(),
7244            |l| l.language.display_name().to_string(),
7245        );
7246    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
7247        r.per_file_records
7248            .iter()
7249            .filter(|f| f.raw_line_categories.test_count > 0)
7250            .count() as u64
7251    });
7252    let total_files_analyzed: u64 = latest_run
7253        .as_ref()
7254        .map_or(0, |r| r.summary_totals.files_analyzed);
7255    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
7256
7257    // Aggregated coverage percentages from summary_totals
7258    let cov_line_pct_str: String = latest_run
7259        .as_ref()
7260        .filter(|r| r.summary_totals.coverage_lines_found > 0)
7261        .map_or_else(
7262            || "0".to_string(),
7263            |r| {
7264                format!(
7265                    "{:.1}",
7266                    r.summary_totals.coverage_lines_hit as f64
7267                        / r.summary_totals.coverage_lines_found as f64
7268                        * 100.0
7269                )
7270            },
7271        );
7272    let cov_fn_pct_str: String = latest_run
7273        .as_ref()
7274        .filter(|r| r.summary_totals.coverage_functions_found > 0)
7275        .map_or_else(
7276            || "0".to_string(),
7277            |r| {
7278                format!(
7279                    "{:.1}",
7280                    r.summary_totals.coverage_functions_hit as f64
7281                        / r.summary_totals.coverage_functions_found as f64
7282                        * 100.0
7283                )
7284            },
7285        );
7286    let cov_branch_pct_str: String = latest_run
7287        .as_ref()
7288        .filter(|r| r.summary_totals.coverage_branches_found > 0)
7289        .map_or_else(
7290            || "0".to_string(),
7291            |r| {
7292                format!(
7293                    "{:.1}",
7294                    r.summary_totals.coverage_branches_hit as f64
7295                        / r.summary_totals.coverage_branches_found as f64
7296                        * 100.0
7297                )
7298            },
7299        );
7300
7301    let cov_no_data_notice = if has_coverage {
7302        String::new()
7303    } else {
7304        String::from(
7305            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
7306<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>
7307<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
7308  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
7309  <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>
7310  <span style="color:var(--muted);font-size:12px;">&middot;</span>
7311  <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>
7312  <span style="color:var(--muted);font-size:12px;">&middot;</span>
7313  <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>
7314</div>
7315<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
7316</div>"#,
7317        )
7318    };
7319
7320    let workspace_density_str = format!("{workspace_density:.1}");
7321    let nonce = &csp_nonce;
7322    let version = env!("CARGO_PKG_VERSION");
7323
7324    let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7325        r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7326            .to_string()
7327    } else {
7328        watched_dirs_list
7329            .iter()
7330            .fold(String::new(), |mut s, d| {
7331                use std::fmt::Write as _;
7332                let escaped =
7333                    d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
7334                write!(
7335                    s,
7336                    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>"#
7337                ).expect("write to String is infallible");
7338                s
7339            })
7340    };
7341    let watched_dirs_html = format!(
7342        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>"#
7343    );
7344
7345    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
7346    let scope_data_json: String = {
7347        let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
7348        scope_map.insert(
7349            "__all__".to_string(),
7350            latest_run.as_ref().map_or_else(
7351                || {
7352                    serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
7353                        "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
7354                        "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
7355                        "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
7356                        "has_coverage":false,"submodules":{}})
7357                },
7358                build_test_scope_entry,
7359            ),
7360        );
7361        let all_roots: Vec<String> = {
7362            let reg = state.registry.lock().await;
7363            let mut seen = std::collections::BTreeSet::new();
7364            reg.entries
7365                .iter()
7366                .flat_map(|e| e.input_roots.iter().cloned())
7367                .filter(|r| seen.insert(r.clone()))
7368                .collect()
7369        };
7370        for root in &all_roots {
7371            let run_for_root: Option<AnalysisRun> = {
7372                let reg = state.registry.lock().await;
7373                let json_str = reg
7374                    .entries
7375                    .iter()
7376                    .find(|e| e.input_roots.iter().any(|r| r == root))
7377                    .and_then(|e| e.json_path.as_ref())
7378                    .and_then(|p| std::fs::read_to_string(p).ok());
7379                drop(reg);
7380                json_str
7381                    .as_deref()
7382                    .and_then(|s| serde_json::from_str(s).ok())
7383            };
7384            if let Some(ref run) = run_for_root {
7385                let mut root_entry = build_test_scope_entry(run);
7386                if !run.submodule_summaries.is_empty() {
7387                    let subs: serde_json::Map<String, serde_json::Value> = run
7388                        .submodule_summaries
7389                        .iter()
7390                        .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
7391                        .collect();
7392                    root_entry["submodules"] = serde_json::Value::Object(subs);
7393                }
7394                scope_map.insert(root.clone(), root_entry);
7395            }
7396        }
7397        serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
7398    };
7399
7400    let html = format!(
7401        r#"<!doctype html>
7402<html lang="en">
7403<head>
7404  <meta charset="utf-8" />
7405  <meta name="viewport" content="width=device-width, initial-scale=1" />
7406  <title>OxideSLOC | Test Metrics</title>
7407  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7408  <style nonce="{nonce}">
7409    :root {{
7410      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7411      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7412      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7413      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7414      --info-bg:#eef3ff; --info-text:#4467d8;
7415    }}
7416    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7417    *{{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);}}
7418    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7419    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7420    .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;}}
7421    @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));}}}}
7422    .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);}}
7423    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7424    .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));}}
7425    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7426    .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;}}
7427    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7428    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7429    @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; }} }}
7430    .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;}}
7431    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7432    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7433    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7434    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7435    .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;}}
7436    .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;}}
7437    .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;}}
7438    .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;}}
7439    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7440    .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);}}
7441    .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;}}
7442    .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;}}
7443    .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;}}
7444    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7445    .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;}}
7446    .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);}}
7447    .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;}}
7448    .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;}}
7449    .tz-select:focus{{border-color:var(--oxide);}}
7450    .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
7451    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7452    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7453    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7454    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7455    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7456    .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;}}
7457    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7458    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7459    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7460    .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;}}
7461    .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;}}
7462    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7463    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7464    .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);}}
7465    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
7466    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
7467    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
7468    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
7469    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
7470    .chart-canvas-wrap{{position:relative;height:280px;}}
7471    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
7472    .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;}}
7473    .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;}}
7474    .data-table tr:last-child td{{border-bottom:none;}}
7475    .data-table tbody tr:hover td{{background:var(--surface-2);}}
7476    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
7477    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
7478    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
7479    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
7480    .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;}}
7481    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
7482    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
7483    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
7484    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
7485    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
7486    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
7487    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
7488    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
7489    .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;}}
7490    .chart-select:focus{{border-color:var(--accent);}}
7491    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7492    .trend-canvas-wrap{{position:relative;height:260px;}}
7493    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7494    .site-footer a{{color:var(--muted);}}
7495    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
7496    .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;}}
7497    .btn:hover{{background:var(--surface-2);}}
7498    .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;}}
7499    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7500    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
7501    .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;}}
7502    .scope-sel:focus{{border-color:var(--accent);}}
7503    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
7504    .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;}}
7505    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7506    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7507    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7508    .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;}}
7509    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7510    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7511    .watched-chip-rm:hover{{color:var(--oxide);}}
7512    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7513    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7514    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7515    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7516    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
7517    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
7518    .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;}}
7519    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
7520    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
7521    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
7522    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
7523    .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;}}
7524    .cov-file-search:focus{{border-color:var(--accent);}}
7525    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
7526    .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;}}
7527    body.dark-theme .cov-file-search{{background:var(--surface);}}
7528  </style>
7529</head>
7530<body>
7531  <div class="background-watermarks" aria-hidden="true">
7532    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7533    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7534    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7535    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7536    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7537    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7538  </div>
7539  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7540  <div class="top-nav">
7541    <div class="top-nav-inner">
7542      <a class="brand" href="/">
7543        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7544        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
7545      </a>
7546      <div class="nav-right">
7547        <a class="nav-pill" href="/">Home</a>
7548        <div class="nav-dropdown">
7549          <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>
7550          <div class="nav-dropdown-menu">
7551            <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>
7552          </div>
7553        </div>
7554        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7555        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
7556        <div class="nav-dropdown">
7557          <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>
7558          <div class="nav-dropdown-menu">
7559            <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>
7560          </div>
7561        </div>
7562        <div class="server-status-wrap">
7563          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
7564          <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>
7565        </div>
7566        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7567          <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>
7568        </button>
7569        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7570          <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>
7571          <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>
7572        </button>
7573      </div>
7574    </div>
7575  </div>
7576
7577  <div class="page">
7578    {watched_dirs_html}
7579    <div class="scope-bar">
7580      <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>
7581      <span class="scope-label">Scope</span>
7582      <div class="scope-sel-wrap">
7583        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
7584        <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);">
7585          <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>
7586          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
7587        </div>
7588      </div>
7589    </div>
7590    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7591      <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>
7592      <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>
7593      <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>
7594      <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>
7595    </div>
7596    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7597      <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>
7598      <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>
7599      <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>
7600      <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>
7601    </div>
7602
7603    <div class="panel">
7604      <h1>Test Metrics</h1>
7605      <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>
7606
7607      <div class="chart-row">
7608        <div class="chart-box">
7609          <div class="chart-box-title">Test Definitions by Language</div>
7610          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
7611        </div>
7612        <div class="chart-box">
7613          <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
7614          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
7615        </div>
7616      </div>
7617
7618      <div class="section-header">Language Breakdown</div>
7619      {cov_no_data_notice}
7620      <div style="overflow-x:auto;">
7621        <table class="data-table" id="lang-table">
7622          <thead><tr>
7623            <th>Language</th>
7624            <th class="num">Test Fns</th>
7625            <th class="num">Assertions</th>
7626            <th class="num">Suites</th>
7627            <th class="num">Code Lines</th>
7628            <th class="num">Files</th>
7629            <th class="num">Density / 1K</th>
7630            <th>Relative Density</th>
7631          </tr></thead>
7632          <tbody id="lang-tbody"></tbody>
7633        </table>
7634      </div>
7635    </div>
7636
7637    <div class="panel" id="cov-panel" style="display:none;">
7638      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
7639      <div class="cov-gauge-row" id="cov-gauges">
7640        <div class="cov-gauge-card">
7641          <div class="cov-gauge-label">Line Coverage</div>
7642          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
7643          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
7644          <div class="cov-gauge-sub">Lines hit / instrumented</div>
7645        </div>
7646        <div class="cov-gauge-card">
7647          <div class="cov-gauge-label">Function Coverage</div>
7648          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
7649          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
7650          <div class="cov-gauge-sub">Functions hit / found</div>
7651        </div>
7652        <div class="cov-gauge-card">
7653          <div class="cov-gauge-label">Branch Coverage</div>
7654          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
7655          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
7656          <div class="cov-gauge-sub">Branches hit / found</div>
7657        </div>
7658      </div>
7659      <div class="chart-row">
7660        <div class="chart-box">
7661          <div class="chart-box-title">Line Coverage % by Language</div>
7662          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
7663        </div>
7664        <div class="chart-box">
7665          <div class="chart-box-title">Coverage Tier Distribution</div>
7666          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
7667        </div>
7668      </div>
7669
7670      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
7671      <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>
7672      <div class="cov-file-toolbar">
7673        <div class="cov-filter-tabs" id="cov-filter-tabs">
7674          <button class="cov-tab active" data-tier="all">All</button>
7675          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
7676          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
7677          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
7678          <button class="cov-tab" data-tier="high">High (≥80%)</button>
7679        </div>
7680        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
7681      </div>
7682      <div style="overflow-x:auto;">
7683        <table class="data-table" id="cov-file-table">
7684          <thead><tr>
7685            <th>File</th>
7686            <th>Lang</th>
7687            <th class="num">Line %</th>
7688            <th class="num">Lines Hit / Found</th>
7689            <th class="num">Fn %</th>
7690            <th class="num">Fns Hit / Found</th>
7691          </tr></thead>
7692          <tbody id="cov-file-tbody"></tbody>
7693        </table>
7694      </div>
7695      <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>
7696      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
7697    </div>
7698
7699    <div class="panel">
7700      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
7701      <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
7702      <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
7703      <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
7704    </div>
7705  </div>
7706
7707  <footer class="site-footer">
7708    oxide-sloc v{version} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
7709    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7710    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7711    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7712    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
7713  </footer>
7714
7715  <script nonce="{nonce}">
7716  (function() {{
7717    // Theme
7718    var b = document.body;
7719    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7720    var tgl = document.getElementById('theme-toggle');
7721    if (tgl) tgl.addEventListener('click', function() {{
7722      var d = b.classList.toggle('dark-theme');
7723      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7724    }});
7725
7726    // Watermarks
7727    (function() {{
7728      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7729      if (!wms.length) return;
7730      var placed = [];
7731      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;}}
7732      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];}}
7733      var half=Math.floor(wms.length/2);
7734      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;}});
7735    }})();
7736
7737    // Code particles
7738    (function() {{
7739      var container = document.getElementById('code-particles');
7740      if (!container) return;
7741      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
7742      for (var i = 0; i < 36; i++) {{
7743        (function(idx) {{
7744          var el = document.createElement('span');
7745          el.className = 'code-particle';
7746          el.textContent = snippets[idx % snippets.length];
7747          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7748          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7749          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7750          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';
7751          container.appendChild(el);
7752        }})(i);
7753      }}
7754    }})();
7755
7756    // Settings modal
7757    (function() {{
7758      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'}}];
7759      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);}});}}
7760      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7761      var btn=document.getElementById('settings-btn');if(!btn)return;
7762      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7763      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>';
7764      document.body.appendChild(m);
7765      var g=document.getElementById('scheme-grid');
7766      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);}});
7767      var cl=document.getElementById('settings-close');
7768      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');}});
7769      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7770      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7771    }})();
7772
7773    // Watched folder picker
7774    (function() {{
7775      var btn = document.getElementById('add-watched-btn');
7776      if (!btn) return;
7777      btn.addEventListener('click', function() {{
7778        fetch('/pick-directory?kind=reports')
7779          .then(function(r) {{ return r.json(); }})
7780          .then(function(data) {{
7781            if (!data.cancelled && data.selected_path) {{
7782              var form = document.createElement('form');
7783              form.method = 'POST';
7784              form.action = '/watched-dirs/add';
7785              var ri = document.createElement('input');
7786              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7787              var fi = document.createElement('input');
7788              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7789              form.appendChild(ri); form.appendChild(fi);
7790              document.body.appendChild(form);
7791              form.submit();
7792            }}
7793          }})
7794          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7795      }});
7796    }})();
7797  }})();
7798  </script>
7799
7800  <script src="/static/chart.js" nonce="{nonce}"></script>
7801  <script nonce="{nonce}">
7802  (function() {{
7803    var SCOPE_DATA = {scope_data_json};
7804    var currentRoot = '__all__';
7805    var currentSub  = '';
7806    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
7807    var ALL_CHARTS = [];
7808
7809    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();}}
7810    function fmtFull(n){{return Number(n).toLocaleString();}}
7811    function isDark(){{return document.body.classList.contains('dark-theme');}}
7812    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
7813    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
7814    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
7815
7816    function getDataset() {{
7817      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7818      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
7819      return r;
7820    }}
7821    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
7822
7823    function renderTestCharts(D) {{
7824      testsChart = destroyChart(testsChart);
7825      densityChart = destroyChart(densityChart);
7826      if (!D || !D.length) return;
7827      var top15 = D.slice(0, 15);
7828      var canvas1 = document.getElementById('canvas-tests');
7829      if (canvas1) {{
7830        testsChart = new Chart(canvas1, {{
7831          type: 'bar',
7832          data: {{
7833            labels: top15.map(function(d){{ return d.lang; }}),
7834            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
7835          }},
7836          options: {{
7837            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7838            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
7839            scales: {{
7840              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
7841              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7842            }}
7843          }}
7844        }});
7845        ALL_CHARTS.push(testsChart);
7846      }}
7847      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
7848      var canvas2 = document.getElementById('canvas-density');
7849      if (canvas2) {{
7850        densityChart = new Chart(canvas2, {{
7851          type: 'bar',
7852          data: {{
7853            labels: topD.map(function(d){{ return d.lang; }}),
7854            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 }}]
7855          }},
7856          options: {{
7857            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7858            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
7859            scales: {{
7860              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
7861              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7862            }}
7863          }}
7864        }});
7865        ALL_CHARTS.push(densityChart);
7866      }}
7867    }}
7868
7869    function renderCovCharts(covD, tiers) {{
7870      covChart = destroyChart(covChart);
7871      tierChart = destroyChart(tierChart);
7872      var covCanvas = document.getElementById('canvas-cov');
7873      if (covCanvas && covD && covD.length) {{
7874        covChart = new Chart(covCanvas, {{
7875          type: 'bar',
7876          data: {{
7877            labels: covD.map(function(d){{ return d.lang; }}),
7878            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 }}]
7879          }},
7880          options: {{
7881            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7882            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
7883            scales: {{
7884              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
7885              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7886            }}
7887          }}
7888        }});
7889        ALL_CHARTS.push(covChart);
7890      }}
7891      var tierCanvas = document.getElementById('canvas-cov-tiers');
7892      if (tierCanvas && tiers) {{
7893        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
7894        tierChart = new Chart(tierCanvas, {{
7895          type: 'doughnut',
7896          data: {{
7897            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
7898            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
7899          }},
7900          options: {{
7901            responsive: true, maintainAspectRatio: false, cutout: '62%',
7902            plugins: {{
7903              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
7904              tooltip: {{ callbacks: {{ label: function(ctx) {{
7905                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
7906                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
7907              }} }} }}
7908            }}
7909          }}
7910        }});
7911        ALL_CHARTS.push(tierChart);
7912      }}
7913    }}
7914
7915    function buildLangTable(D) {{
7916      var tbody = document.getElementById('lang-tbody');
7917      if (!tbody) return;
7918      if (!D || !D.length) {{
7919        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>';
7920        return;
7921      }}
7922      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
7923      tbody.innerHTML = D.map(function(d) {{
7924        var barW = Math.round(d.density / maxDensity * 120);
7925        return '<tr>' +
7926          '<td><strong>' + d.lang + '</strong></td>' +
7927          '<td class="num">' + fmt(d.tests) + '</td>' +
7928          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
7929          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
7930          '<td class="num">' + fmt(d.code) + '</td>' +
7931          '<td class="num">' + fmt(d.files) + '</td>' +
7932          '<td class="num">' + d.density.toFixed(2) + '</td>' +
7933          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
7934          '</tr>';
7935      }}).join('');
7936    }}
7937
7938    var covFileData = [];
7939    var covFileTier = 'all';
7940    var covFileSearch = '';
7941
7942    function pctBadge(pct) {{
7943      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
7944      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
7945      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
7946    }}
7947
7948    function buildCovFileTable() {{
7949      var tbody = document.getElementById('cov-file-tbody');
7950      var empty = document.getElementById('cov-file-empty');
7951      var count = document.getElementById('cov-file-count');
7952      if (!tbody) return;
7953      var srch = covFileSearch.toLowerCase();
7954      var filtered = covFileData.filter(function(f) {{
7955        if (covFileTier === 'zero' && f.line_pct > 0) return false;
7956        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
7957        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
7958        if (covFileTier === 'high' && f.line_pct < 80) return false;
7959        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
7960        return true;
7961      }});
7962      if (!filtered.length) {{
7963        tbody.innerHTML = '';
7964        if (empty) empty.style.display = '';
7965        if (count) count.textContent = '';
7966        return;
7967      }}
7968      if (empty) empty.style.display = 'none';
7969      var shown = Math.min(filtered.length, 500);
7970      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
7971      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
7972        var fnCol = f.fn_pct < 0
7973          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
7974          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
7975        return '<tr>' +
7976          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
7977          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
7978          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
7979          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
7980          fnCol +
7981          '</tr>';
7982      }}).join('');
7983    }}
7984
7985    (function() {{
7986      var tabs = document.getElementById('cov-filter-tabs');
7987      if (tabs) {{
7988        tabs.addEventListener('click', function(e) {{
7989          var btn = e.target.closest('.cov-tab');
7990          if (!btn) return;
7991          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
7992          btn.classList.add('active');
7993          covFileTier = btn.getAttribute('data-tier');
7994          buildCovFileTable();
7995        }});
7996      }}
7997      var srch = document.getElementById('cov-file-search');
7998      if (srch) {{
7999        srch.addEventListener('input', function() {{
8000          covFileSearch = this.value;
8001          buildCovFileTable();
8002        }});
8003      }}
8004    }})();
8005
8006    function updateCovGauges(t) {{
8007      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
8008      var el;
8009      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
8010      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
8011      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
8012      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
8013      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
8014      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
8015    }}
8016
8017    function applyScope() {{
8018      var d = getDataset();
8019      var t = d.totals;
8020      var el;
8021      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
8022      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
8023      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
8024      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
8025      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
8026      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
8027      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
8028      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
8029      renderTestCharts(d.lang_tests);
8030      buildLangTable(d.lang_tests);
8031      var covPanel = document.getElementById('cov-panel');
8032      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
8033      if (d.has_coverage) {{
8034        renderCovCharts(d.cov, d.cov_tiers);
8035        updateCovGauges(t);
8036        covFileData = d.file_cov || [];
8037        covFileTier = 'all';
8038        covFileSearch = '';
8039        var tabs = document.getElementById('cov-filter-tabs');
8040        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
8041        var srch = document.getElementById('cov-file-search');
8042        if (srch) srch.value = '';
8043        buildCovFileTable();
8044      }}
8045      loadTrend();
8046    }}
8047
8048    // Populate scope-root-sel from SCOPE_DATA keys
8049    (function() {{
8050      var sel = document.getElementById('scope-root-sel');
8051      if (!sel) return;
8052      Object.keys(SCOPE_DATA).forEach(function(k) {{
8053        if (k === '__all__') return;
8054        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
8055      }});
8056    }})();
8057
8058    document.getElementById('scope-root-sel').addEventListener('change', function() {{
8059      currentRoot = this.value;
8060      currentSub = '';
8061      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
8062      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
8063      var subWrap = document.getElementById('scope-sub-wrap');
8064      var subSel  = document.getElementById('scope-sub-sel');
8065      subSel.innerHTML = '<option value="">Entire project</option>';
8066      if (subNames.length) {{
8067        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
8068        subWrap.style.display = 'flex';
8069      }} else {{
8070        subWrap.style.display = 'none';
8071      }}
8072      applyScope();
8073    }});
8074
8075    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
8076      currentSub = this.value;
8077      applyScope();
8078    }});
8079
8080    function buildTrend(data) {{
8081      var trendCanvas = document.getElementById('canvas-trend');
8082      var trendEmpty  = document.getElementById('trend-empty');
8083      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
8084      pts = pts.slice().reverse();
8085      if (!pts.length) {{
8086        if (trendCanvas) trendCanvas.style.display = 'none';
8087        if (trendEmpty) trendEmpty.style.display = '';
8088        return;
8089      }}
8090      if (trendCanvas) trendCanvas.style.display = '';
8091      if (trendEmpty) trendEmpty.style.display = 'none';
8092      trendChart = destroyChart(trendChart);
8093      if (!trendCanvas) return;
8094      trendChart = new Chart(trendCanvas, {{
8095        type: 'line',
8096        data: {{
8097          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
8098          datasets: [{{
8099            label: 'Test Definitions',
8100            data: pts.map(function(d){{ return d.test_count; }}),
8101            borderColor: '#C45C10',
8102            backgroundColor: 'rgba(196,92,16,0.10)',
8103            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
8104            pointRadius: 5, fill: true, tension: 0.3
8105          }}]
8106        }},
8107        options: {{
8108          responsive: true, maintainAspectRatio: false,
8109          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
8110          scales: {{
8111            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
8112            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
8113          }}
8114        }}
8115      }});
8116      ALL_CHARTS.push(trendChart);
8117    }}
8118
8119    function loadTrend() {{
8120      var url = '/api/metrics/history?limit=100';
8121      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
8122      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
8123        buildTrend(data);
8124      }}).catch(function(){{
8125        var trendEmpty = document.getElementById('trend-empty');
8126        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
8127      }});
8128    }}
8129
8130    // Re-render charts on theme toggle
8131    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
8132      setTimeout(function() {{
8133        ALL_CHARTS.forEach(function(c) {{
8134          if (c && c.options && c.options.scales) {{
8135            Object.values(c.options.scales).forEach(function(ax) {{
8136              if (ax.grid) ax.grid.color = clr();
8137              if (ax.ticks) ax.ticks.color = txtClr();
8138            }});
8139            c.update();
8140          }}
8141        }});
8142      }}, 80);
8143    }});
8144
8145    applyScope();
8146  }})();
8147  </script>
8148</body>
8149</html>"#,
8150    );
8151    Html(html).into_response()
8152}
8153
8154// ── Embeddable widget ─────────────────────────────────────────────────────────
8155// Protected. Returns a self-contained HTML page suitable for iframing inside
8156// Jenkins build summaries, Confluence iframe macros, or Jira panels.
8157//
8158// GET /embed/summary?run_id=<uuid>&theme=dark
8159
8160#[derive(Deserialize)]
8161struct EmbedQuery {
8162    run_id: Option<String>,
8163    theme: Option<String>,
8164}
8165
8166async fn embed_handler(
8167    State(state): State<AppState>,
8168    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8169    Query(query): Query<EmbedQuery>,
8170) -> Response {
8171    let entry = {
8172        let reg = state.registry.lock().await;
8173        query.run_id.as_ref().map_or_else(
8174            || reg.entries.first().cloned(),
8175            |id| reg.find_by_run_id(id).cloned(),
8176        )
8177    };
8178
8179    let Some(entry) = entry else {
8180        return Html(
8181            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
8182                .to_string(),
8183        )
8184        .into_response();
8185    };
8186
8187    let dark = query.theme.as_deref() == Some("dark");
8188    let languages: Vec<(String, u64, u64)> = entry
8189        .json_path
8190        .as_ref()
8191        .and_then(|p| read_json(p).ok())
8192        .map(|run| {
8193            run.totals_by_language
8194                .iter()
8195                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
8196                .collect()
8197        })
8198        .unwrap_or_default();
8199
8200    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
8201}
8202
8203fn render_embed_widget(
8204    entry: &RegistryEntry,
8205    languages: &[(String, u64, u64)],
8206    dark: bool,
8207    csp_nonce: &str,
8208) -> String {
8209    let s = &entry.summary;
8210    let total = s.code_lines + s.comment_lines + s.blank_lines;
8211    let code_pct = s
8212        .code_lines
8213        .checked_mul(100)
8214        .and_then(|n| n.checked_div(total))
8215        .unwrap_or(0);
8216
8217    let (bg, fg, surface, muted, border) = if dark {
8218        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
8219    } else {
8220        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
8221    };
8222
8223    let mut lang_rows = String::new();
8224    for (name, files, code) in languages {
8225        write!(
8226            lang_rows,
8227            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
8228            escape_html(name),
8229            format_number(*files),
8230            format_number(*code),
8231        )
8232        .ok();
8233    }
8234
8235    let lang_table = if lang_rows.is_empty() {
8236        String::new()
8237    } else {
8238        format!(
8239            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
8240        )
8241    };
8242
8243    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
8244    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
8245    let project_esc = escape_html(&entry.project_label);
8246    let code_lines = format_number(s.code_lines);
8247    let comment_lines = format_number(s.comment_lines);
8248    let files = format_number(s.files_analyzed);
8249    let code_raw = s.code_lines;
8250    let comment_raw = s.comment_lines;
8251    let blank_raw = s.blank_lines;
8252
8253    format!(
8254        r#"<!doctype html>
8255<html lang="en">
8256<head>
8257  <meta charset="utf-8">
8258  <meta name="viewport" content="width=device-width,initial-scale=1">
8259  <title>OxideSLOC &mdash; {project_esc}</title>
8260  <script src="/static/chart.js"></script>
8261  <style nonce="{csp_nonce}">
8262    *{{box-sizing:border-box;margin:0;padding:0}}
8263    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
8264    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
8265    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
8266    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
8267    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
8268    .card .v{{font-size:18px;font-weight:700}}
8269    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
8270    .row{{display:flex;gap:12px;align-items:flex-start}}
8271    .pie{{width:120px;height:120px;flex-shrink:0}}
8272    .lt{{border-collapse:collapse;width:100%;flex:1}}
8273    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
8274    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
8275    .n{{text-align:right}}
8276    .footer{{margin-top:10px;color:{muted};font-size:10px}}
8277  </style>
8278</head>
8279<body>
8280  <h2>{project_esc}</h2>
8281  <div class="sub">{timestamp} &middot; run {run_short}</div>
8282  <div class="cards">
8283    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
8284    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
8285    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
8286    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
8287  </div>
8288  <div class="row">
8289    <canvas class="pie" id="c"></canvas>
8290    {lang_table}
8291  </div>
8292  <div class="footer">oxide-sloc</div>
8293  <script nonce="{csp_nonce}">
8294    new Chart(document.getElementById('c'),{{
8295      type:'doughnut',
8296      data:{{
8297        labels:['Code','Comments','Blank'],
8298        datasets:[{{
8299          data:[{code_raw},{comment_raw},{blank_raw}],
8300          backgroundColor:['#4a78ee','#b35428','#aaa'],
8301          borderWidth:0
8302        }}]
8303      }},
8304      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
8305    }});
8306  </script>
8307</body>
8308</html>"#
8309    )
8310}
8311
8312#[allow(clippy::too_many_arguments)]
8313fn persist_run_artifacts(
8314    run: &sloc_core::AnalysisRun,
8315    report_html: &str,
8316    run_dir: &Path,
8317    generate_json: bool,
8318    generate_html: bool,
8319    generate_pdf: bool,
8320    report_title: &str,
8321    file_stem: &str,
8322    result_context: RunResultContext,
8323) -> Result<(RunArtifacts, PendingPdf)> {
8324    fs::create_dir_all(run_dir)
8325        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
8326
8327    let mut html_path = None;
8328    let mut pdf_path = None;
8329    let mut json_path = None;
8330    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
8331
8332    if generate_html {
8333        let path = run_dir.join(format!("report_{file_stem}.html"));
8334        fs::write(&path, report_html)
8335            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
8336        html_path = Some(path);
8337    }
8338
8339    if generate_json {
8340        let path = run_dir.join(format!("result_{file_stem}.json"));
8341        let json = serde_json::to_string_pretty(run)
8342            .context("failed to serialize analysis run to JSON")?;
8343        fs::write(&path, json)
8344            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
8345        json_path = Some(path);
8346    }
8347
8348    if generate_pdf {
8349        let source_html_path = if let Some(existing) = html_path.as_ref() {
8350            existing.clone()
8351        } else {
8352            let temp_html = run_dir.join("_report_rendered.html");
8353            fs::write(&temp_html, report_html).with_context(|| {
8354                format!(
8355                    "failed to write temporary HTML report to {}",
8356                    temp_html.display()
8357                )
8358            })?;
8359            temp_html
8360        };
8361
8362        let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
8363        let cleanup_src = !generate_html;
8364        pdf_path = Some(pdf_dest.clone());
8365        pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
8366    }
8367
8368    // CSV and XLSX are always generated (like JSON) — no extra flag required.
8369    let csv_path = {
8370        let path = run_dir.join(format!("report_{file_stem}.csv"));
8371        if let Err(e) = sloc_report::write_csv(run, &path) {
8372            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
8373            None
8374        } else {
8375            Some(path)
8376        }
8377    };
8378
8379    let xlsx_path = {
8380        let path = run_dir.join(format!("report_{file_stem}.xlsx"));
8381        if let Err(e) = sloc_report::write_xlsx(run, &path) {
8382            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
8383            None
8384        } else {
8385            Some(path)
8386        }
8387    };
8388
8389    let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
8390
8391    Ok((
8392        RunArtifacts {
8393            output_dir: run_dir.to_path_buf(),
8394            html_path,
8395            pdf_path,
8396            json_path,
8397            csv_path,
8398            xlsx_path,
8399            scan_config_path,
8400            report_title: report_title.to_string(),
8401            result_context,
8402        },
8403        pending_pdf,
8404    ))
8405}
8406
8407/// Find a scan-config JSON file in `dir`, checking both the legacy fixed name and
8408/// the current `scan-config_<stem>.json` pattern for backwards compatibility.
8409fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
8410    let exact = dir.join("scan-config.json");
8411    if exact.exists() {
8412        return Some(exact);
8413    }
8414    fs::read_dir(dir).ok().and_then(|entries| {
8415        entries
8416            .filter_map(std::result::Result::ok)
8417            .find(|e| {
8418                let name = e.file_name();
8419                let name = name.to_string_lossy();
8420                name.starts_with("scan-config") && name.ends_with(".json")
8421            })
8422            .map(|e| e.path())
8423    })
8424}
8425
8426// ── Config export / import ────────────────────────────────────────────────────
8427
8428async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
8429    let toml_str = match toml::to_string_pretty(&state.base_config) {
8430        Ok(s) => s,
8431        Err(e) => {
8432            return (
8433                StatusCode::INTERNAL_SERVER_ERROR,
8434                format!("serialization error: {e}"),
8435            )
8436                .into_response();
8437        }
8438    };
8439    (
8440        [
8441            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
8442            (
8443                header::CONTENT_DISPOSITION,
8444                "attachment; filename=\".oxide-sloc.toml\"",
8445            ),
8446        ],
8447        toml_str,
8448    )
8449        .into_response()
8450}
8451
8452#[derive(Deserialize)]
8453struct ImportConfigBody {
8454    toml: String,
8455}
8456
8457async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
8458    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
8459        Ok(config) => {
8460            if let Err(e) = config.validate() {
8461                return (
8462                    StatusCode::UNPROCESSABLE_ENTITY,
8463                    Json(serde_json::json!({ "error": e.to_string() })),
8464                )
8465                    .into_response();
8466            }
8467            Json(serde_json::json!({ "ok": true, "config": config })).into_response()
8468        }
8469        Err(e) => (
8470            StatusCode::BAD_REQUEST,
8471            Json(serde_json::json!({ "error": format!("TOML parse error: {e}") })),
8472        )
8473            .into_response(),
8474    }
8475}
8476
8477// ── Scan profiles API ─────────────────────────────────────────────────────────
8478
8479async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
8480    let store = state.scan_profiles.lock().await;
8481    Json(serde_json::json!({ "profiles": store.profiles }))
8482}
8483
8484#[derive(Deserialize)]
8485struct SaveScanProfileBody {
8486    name: String,
8487    params: serde_json::Value,
8488}
8489
8490async fn api_save_scan_profile(
8491    State(state): State<AppState>,
8492    Json(body): Json<SaveScanProfileBody>,
8493) -> impl IntoResponse {
8494    if body.name.trim().is_empty() {
8495        return (
8496            StatusCode::BAD_REQUEST,
8497            Json(serde_json::json!({ "error": "name must not be empty" })),
8498        )
8499            .into_response();
8500    }
8501
8502    let id = uuid::Uuid::new_v4().to_string();
8503    let profile = ScanProfile {
8504        id: id.clone(),
8505        name: body.name.trim().to_string(),
8506        created_at: chrono::Utc::now().to_rfc3339(),
8507        params: body.params,
8508    };
8509
8510    let mut store = state.scan_profiles.lock().await;
8511    store.profiles.push(profile);
8512    if let Err(e) = store.save(&state.scan_profiles_path) {
8513        tracing::warn!("failed to persist scan profiles: {e}");
8514    }
8515    drop(store);
8516
8517    (
8518        StatusCode::CREATED,
8519        Json(serde_json::json!({ "ok": true, "id": id })),
8520    )
8521        .into_response()
8522}
8523
8524async fn api_delete_scan_profile(
8525    State(state): State<AppState>,
8526    AxumPath(id): AxumPath<String>,
8527) -> impl IntoResponse {
8528    let mut store = state.scan_profiles.lock().await;
8529    let before = store.profiles.len();
8530    store.profiles.retain(|p| p.id != id);
8531    if store.profiles.len() == before {
8532        drop(store);
8533        return (
8534            StatusCode::NOT_FOUND,
8535            Json(serde_json::json!({ "error": "profile not found" })),
8536        )
8537            .into_response();
8538    }
8539    if let Err(e) = store.save(&state.scan_profiles_path) {
8540        tracing::warn!("failed to persist scan profiles: {e}");
8541    }
8542    drop(store);
8543    Json(serde_json::json!({ "ok": true })).into_response()
8544}
8545
8546fn resolve_output_root(raw: Option<&str>) -> PathBuf {
8547    let value = raw.unwrap_or("out/web").trim();
8548    let path = if value.is_empty() {
8549        PathBuf::from("out/web")
8550    } else {
8551        PathBuf::from(value)
8552    };
8553
8554    if path.is_absolute() {
8555        path
8556    } else {
8557        workspace_root().join(path)
8558    }
8559}
8560
8561/// Derive the directory that holds remote-repo clones from the output root.
8562fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
8563    std::env::var("SLOC_GIT_CLONES_DIR")
8564        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
8565}
8566
8567/// Build a deterministic filesystem path for a cloned remote repository.
8568/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
8569pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
8570    let safe: String = repo_url
8571        .chars()
8572        .map(|c| {
8573            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
8574                c
8575            } else {
8576                '_'
8577            }
8578        })
8579        .take(80)
8580        .collect();
8581    clones_dir.join(safe)
8582}
8583
8584/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
8585/// Runs synchronously — call from `tokio::task::spawn_blocking`.
8586pub(crate) fn scan_path_to_artifacts(
8587    scan_path: &Path,
8588    base_config: &AppConfig,
8589    label: &str,
8590) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
8591    let mut config = base_config.clone();
8592    config.discovery.root_paths = vec![scan_path.to_path_buf()];
8593    label.clone_into(&mut config.reporting.report_title);
8594    let run = analyze(&config, "git", None)?;
8595    let html = render_html(&run)?;
8596    let run_id = run.tool.run_id.clone();
8597    let project_label = sanitize_project_label(label);
8598    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8599    let file_stem = {
8600        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
8601        if commit.is_empty() {
8602            project_label
8603        } else {
8604            format!("{project_label}_{commit}")
8605        }
8606    };
8607    let (artifacts, _pending_pdf) = persist_run_artifacts(
8608        &run,
8609        &html,
8610        &output_dir,
8611        true,
8612        true,
8613        false,
8614        label,
8615        &file_stem,
8616        RunResultContext::default(),
8617    )?;
8618    Ok((run_id, artifacts, run))
8619}
8620
8621/// Re-spawn background poll tasks for any polling schedules saved to disk.
8622async fn restart_poll_schedules(state: &AppState) {
8623    let store = state.schedules.lock().await;
8624    let poll_schedules: Vec<_> = store
8625        .schedules
8626        .iter()
8627        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
8628        .cloned()
8629        .collect();
8630    drop(store);
8631    for schedule in poll_schedules {
8632        let interval = schedule.interval_secs.unwrap_or(300);
8633        let st = state.clone();
8634        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
8635    }
8636}
8637
8638fn split_patterns(raw: Option<&str>) -> Vec<String> {
8639    raw.unwrap_or("")
8640        .lines()
8641        .flat_map(|line| line.split(','))
8642        .map(str::trim)
8643        .filter(|part| !part.is_empty())
8644        .map(ToOwned::to_owned)
8645        .collect()
8646}
8647
8648fn build_sub_run(
8649    parent: &AnalysisRun,
8650    sub: &sloc_core::SubmoduleSummary,
8651    parent_path: &str,
8652) -> AnalysisRun {
8653    let sub_files: Vec<_> = parent
8654        .per_file_records
8655        .iter()
8656        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8657        .cloned()
8658        .collect();
8659    let mut config = parent.effective_configuration.clone();
8660    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
8661    AnalysisRun {
8662        tool: parent.tool.clone(),
8663        environment: parent.environment.clone(),
8664        effective_configuration: config,
8665        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
8666        summary_totals: SummaryTotals {
8667            files_considered: sub.files_analyzed,
8668            files_analyzed: sub.files_analyzed,
8669            files_skipped: 0,
8670            total_physical_lines: sub.total_physical_lines,
8671            code_lines: sub.code_lines,
8672            comment_lines: sub.comment_lines,
8673            blank_lines: sub.blank_lines,
8674            mixed_lines_separate: 0,
8675            functions: 0,
8676            classes: 0,
8677            variables: 0,
8678            imports: 0,
8679            test_count: 0,
8680            test_assertion_count: 0,
8681            test_suite_count: 0,
8682            coverage_lines_found: 0,
8683            coverage_lines_hit: 0,
8684            coverage_functions_found: 0,
8685            coverage_functions_hit: 0,
8686            coverage_branches_found: 0,
8687            coverage_branches_hit: 0,
8688        },
8689        totals_by_language: sub.language_summaries.clone(),
8690        per_file_records: sub_files,
8691        skipped_file_records: vec![],
8692        warnings: vec![],
8693        submodule_summaries: vec![],
8694        git_commit_short: parent.git_commit_short.clone(),
8695        git_commit_long: parent.git_commit_long.clone(),
8696        git_branch: parent.git_branch.clone(),
8697        git_commit_author: parent.git_commit_author.clone(),
8698        git_commit_date: parent.git_commit_date.clone(),
8699        git_tags: parent.git_tags.clone(),
8700        git_nearest_tag: parent.git_nearest_tag.clone(),
8701    }
8702}
8703
8704pub(crate) fn sanitize_project_label(raw: &str) -> String {
8705    let candidate = Path::new(raw)
8706        .file_name()
8707        .and_then(|name| name.to_str())
8708        .unwrap_or("project");
8709
8710    let mut value = String::with_capacity(candidate.len());
8711    for ch in candidate.chars() {
8712        if ch.is_ascii_alphanumeric() {
8713            value.push(ch.to_ascii_lowercase());
8714        } else {
8715            value.push('-');
8716        }
8717    }
8718
8719    let compact = value.trim_matches('-').to_string();
8720    if compact.is_empty() {
8721        "project".to_string()
8722    } else {
8723        compact
8724    }
8725}
8726
8727/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
8728/// comparisons with non-canonicalized stored paths work correctly.
8729fn strip_unc_prefix(path: PathBuf) -> PathBuf {
8730    let s = path.to_string_lossy();
8731    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8732        return PathBuf::from(format!(r"\\{rest}"));
8733    }
8734    if let Some(rest) = s.strip_prefix(r"\\?\") {
8735        return PathBuf::from(rest);
8736    }
8737    path
8738}
8739
8740fn display_path(path: &Path) -> String {
8741    let s = path.to_string_lossy();
8742    // Strip Windows extended-length prefix for display only; the underlying
8743    // PathBuf remains unchanged so file operations are unaffected.
8744    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
8745    // \\?\C:\path           →  C:\path          (local drive)
8746    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8747        return format!(r"\\{rest}");
8748    }
8749    if let Some(rest) = s.strip_prefix(r"\\?\") {
8750        return rest.to_owned();
8751    }
8752    s.into_owned()
8753}
8754
8755fn sanitize_path_str(s: &str) -> String {
8756    // Forward-slash variants of the Windows extended-length prefix that appear
8757    // when paths stored as plain strings have been processed through some path
8758    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
8759    if let Some(rest) = s.strip_prefix("//?/UNC/") {
8760        return format!("//{rest}");
8761    }
8762    if let Some(rest) = s.strip_prefix("//?/") {
8763        return rest.to_owned();
8764    }
8765    display_path(Path::new(s))
8766}
8767
8768fn workspace_root() -> PathBuf {
8769    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
8770    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
8771        let p = PathBuf::from(root);
8772        if p.is_dir() {
8773            return p;
8774        }
8775    }
8776
8777    // Current working directory — works for `cargo run` from the project root
8778    // and for scripts/run.sh which cds there first.
8779    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
8780}
8781
8782/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
8783fn make_git_label(repo: &str, ref_name: &str) -> String {
8784    if repo.is_empty() || ref_name.is_empty() {
8785        return String::new();
8786    }
8787    let base = repo
8788        .trim_end_matches('/')
8789        .trim_end_matches(".git")
8790        .rsplit('/')
8791        .next()
8792        .unwrap_or("repo");
8793    let ref_safe: String = ref_name
8794        .chars()
8795        .map(|c| {
8796            if c.is_alphanumeric() || c == '-' || c == '.' {
8797                c
8798            } else {
8799                '_'
8800            }
8801        })
8802        .collect();
8803    format!("{base}_at_{ref_safe}_sloc")
8804}
8805
8806/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
8807fn desktop_dir() -> PathBuf {
8808    if let Ok(profile) = std::env::var("USERPROFILE") {
8809        let p = PathBuf::from(profile).join("Desktop");
8810        if p.exists() {
8811            return p;
8812        }
8813    }
8814    if let Ok(home) = std::env::var("HOME") {
8815        let p = PathBuf::from(home).join("Desktop");
8816        if p.exists() {
8817            return p;
8818        }
8819    }
8820    workspace_root().join("out").join("web")
8821}
8822
8823fn resolve_input_path(raw: &str) -> PathBuf {
8824    let trimmed = raw.trim();
8825    if trimmed.is_empty() {
8826        return workspace_root().join("samples").join("basic");
8827    }
8828
8829    let candidate = PathBuf::from(trimmed);
8830    let resolved = if candidate.is_absolute() {
8831        candidate
8832    } else {
8833        let rooted = workspace_root().join(&candidate);
8834        if rooted.exists() {
8835            rooted
8836        } else {
8837            workspace_root().join(candidate)
8838        }
8839    };
8840
8841    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
8842    // strip that prefix so stored paths and the displayed "Project path" are clean.
8843    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
8844    PathBuf::from(display_path(&canonical))
8845}
8846
8847fn dir_size_bytes(path: &Path) -> u64 {
8848    let mut total = 0u64;
8849    if let Ok(rd) = fs::read_dir(path) {
8850        for entry in rd.filter_map(Result::ok) {
8851            let p = entry.path();
8852            if p.is_file() {
8853                if let Ok(meta) = p.metadata() {
8854                    total += meta.len();
8855                }
8856            } else if p.is_dir() {
8857                total += dir_size_bytes(&p);
8858            }
8859        }
8860    }
8861    total
8862}
8863
8864#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
8865fn format_dir_size(bytes: u64) -> String {
8866    if bytes >= 1_073_741_824 {
8867        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
8868    } else if bytes >= 1_048_576 {
8869        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
8870    } else if bytes >= 1_024 {
8871        format!("{:.0} KB", bytes as f64 / 1_024.0)
8872    } else {
8873        format!("{bytes} B")
8874    }
8875}
8876
8877#[allow(clippy::too_many_lines)]
8878fn build_preview_html(
8879    // NOSONAR(rust:S3776)
8880    root: &Path,
8881    include_patterns: &[String],
8882    exclude_patterns: &[String],
8883) -> Result<String> {
8884    if !root.exists() {
8885        return Ok(format!(
8886            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
8887            escape_html(&display_path(root))
8888        ));
8889    }
8890
8891    let _selected = display_path(root);
8892    let mut stats = PreviewStats::default();
8893    let mut rows = Vec::new();
8894    let mut languages = Vec::new();
8895    let mut budget = PreviewBudget {
8896        shown: 0,
8897        max_entries: 600,
8898        max_depth: 9,
8899    };
8900    let mut next_row_id = 1usize;
8901
8902    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
8903        || root.to_string_lossy().into_owned(),
8904        std::string::ToString::to_string,
8905    );
8906    let root_modified = root
8907        .metadata()
8908        .ok()
8909        .and_then(|meta| meta.modified().ok())
8910        .map_or_else(|| "-".to_string(), format_system_time);
8911
8912    rows.push(PreviewRow {
8913        row_id: 0,
8914        parent_row_id: None,
8915        depth: 0,
8916        name: format!("{root_name}/"),
8917        kind: PreviewKind::Dir,
8918        is_dir: true,
8919        language: None,
8920        modified: root_modified,
8921        type_label: "Directory".to_string(),
8922    });
8923    collect_preview_rows(
8924        root,
8925        root,
8926        0,
8927        Some(0),
8928        &mut next_row_id,
8929        &mut budget,
8930        &mut stats,
8931        &mut rows,
8932        &mut languages,
8933        include_patterns,
8934        exclude_patterns,
8935    )?;
8936
8937    let root_size = format_dir_size(dir_size_bytes(root));
8938
8939    let mut out = String::new();
8940    write!(
8941        out,
8942        r#"<div class="explorer-wrap" data-project-size="{}">"#,
8943        escape_html(&root_size)
8944    )
8945    .ok();
8946    out.push_str(r#"<div class="explorer-toolbar compact">"#);
8947    out.push_str(r#"<div class="explorer-title-group">"#);
8948    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
8949    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
8950    out.push_str(r"</div></div>");
8951
8952    out.push_str(r#"<div class="scope-stats">"#);
8953    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();
8954    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();
8955    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();
8956    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();
8957    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();
8958    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>"#);
8959    out.push_str(r"</div>");
8960
8961    let submodules = sloc_core::detect_submodules(root);
8962    if !submodules.is_empty() {
8963        let count = submodules.len();
8964        out.push_str(r#"<div class="submodule-preview-strip">"#);
8965        write!(
8966            out,
8967            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>{}</strong>&nbsp;git&nbsp;submodule{}&nbsp;detected</div>"#,
8968            count,
8969            if count == 1 { "" } else { "s" }
8970        )
8971        .ok();
8972        out.push_str(r#"<div class="submodule-preview-chips">"#);
8973        for (sub_name, sub_rel_path) in &submodules {
8974            let sub_abs = root.join(sub_rel_path);
8975            let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
8976            let mut sub_stats = PreviewStats::default();
8977            let mut sub_rows: Vec<PreviewRow> = Vec::new();
8978            let mut sub_langs: Vec<&'static str> = Vec::new();
8979            let mut sub_budget = PreviewBudget {
8980                shown: 0,
8981                max_entries: 2000,
8982                max_depth: 9,
8983            };
8984            let mut sub_next_id = 1usize;
8985            let _ = collect_preview_rows(
8986                &sub_abs,
8987                &sub_abs,
8988                0,
8989                None,
8990                &mut sub_next_id,
8991                &mut sub_budget,
8992                &mut sub_stats,
8993                &mut sub_rows,
8994                &mut sub_langs,
8995                &[],
8996                &[],
8997            );
8998            let stats_json = format!(
8999                r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
9000                sub_stats.directories,
9001                sub_stats.files,
9002                sub_stats.supported,
9003                sub_stats.skipped,
9004                sub_stats.unsupported
9005            );
9006            write!(
9007                out,
9008                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>"#,
9009                escape_html(sub_name),
9010                escape_html(&sub_rel_path.to_string_lossy()),
9011                escape_html(&sub_size),
9012                escape_html(&stats_json),
9013                escape_html(sub_name),
9014                escape_html(&sub_size),
9015            )
9016            .ok();
9017        }
9018        out.push_str(r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#);
9019        out.push_str(r"</div>");
9020    }
9021
9022    out.push_str(r#"<div class="scope-info-row">"#);
9023    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
9024    if languages.is_empty() {
9025        out.push_str(
9026            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
9027        );
9028    } else {
9029        out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
9030        for language in &languages {
9031            if let Some(icon) = language_icon_file(language) {
9032                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();
9033            } else if let Some(svg) = language_inline_svg(language) {
9034                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();
9035            } else {
9036                write!(
9037                    out,
9038                    r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
9039                    escape_html(&language.to_ascii_lowercase()),
9040                    escape_html(language)
9041                )
9042                .ok();
9043            }
9044        }
9045    }
9046    out.push_str(r"</div></div>");
9047    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>"#);
9048    out.push_str(r"</div>");
9049
9050    out.push_str(r#"<div class="file-explorer-shell">"#);
9051    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>"#);
9052    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>"#);
9053    out.push_str(r#"<div class="file-explorer-tree">"#);
9054    for row in rows {
9055        let status_label = row.kind.label();
9056        let lang_attr = row.language.unwrap_or("");
9057        let toggle_html = if row.is_dir {
9058            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
9059                .to_string()
9060        } else {
9061            r#"<span class="tree-bullet">•</span>"#.to_string()
9062        };
9063        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();
9064    }
9065    if budget.shown >= budget.max_entries {
9066        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>"#);
9067    }
9068    out.push_str(r"</div></div></div>");
9069
9070    Ok(out)
9071}
9072
9073#[derive(Default)]
9074struct PreviewStats {
9075    directories: usize,
9076    files: usize,
9077    supported: usize,
9078    skipped: usize,
9079    unsupported: usize,
9080}
9081
9082struct PreviewRow {
9083    row_id: usize,
9084    parent_row_id: Option<usize>,
9085    depth: usize,
9086    name: String,
9087    kind: PreviewKind,
9088    is_dir: bool,
9089    language: Option<&'static str>,
9090    modified: String,
9091    type_label: String,
9092}
9093
9094#[derive(Copy, Clone)]
9095enum PreviewKind {
9096    Dir,
9097    Supported,
9098    Skipped,
9099    Unsupported,
9100}
9101
9102impl PreviewKind {
9103    const fn filter_key(self) -> &'static str {
9104        match self {
9105            Self::Dir => "dir",
9106            Self::Supported => "supported",
9107            Self::Skipped => "skipped",
9108            Self::Unsupported => "unsupported",
9109        }
9110    }
9111
9112    const fn label(self) -> &'static str {
9113        match self {
9114            Self::Dir => "dir",
9115            Self::Supported => "supported",
9116            Self::Skipped => "skipped by policy",
9117            Self::Unsupported => "unsupported",
9118        }
9119    }
9120
9121    const fn badge_class(self) -> &'static str {
9122        match self {
9123            Self::Dir => "badge badge-dir",
9124            Self::Supported => "badge badge-scan",
9125            Self::Skipped => "badge badge-skip",
9126            Self::Unsupported => "badge badge-unsupported",
9127        }
9128    }
9129
9130    const fn node_class(self) -> &'static str {
9131        match self {
9132            Self::Dir => "tree-node-dir",
9133            Self::Supported => "tree-node-supported",
9134            Self::Skipped => "tree-node-skipped",
9135            Self::Unsupported => "tree-node-unsupported",
9136        }
9137    }
9138}
9139
9140struct PreviewBudget {
9141    shown: usize,
9142    max_entries: usize,
9143    max_depth: usize,
9144}
9145
9146/// Handle a single directory entry inside `collect_preview_rows`.
9147/// Returns `true` when the entry was handled (caller should `continue`).
9148#[allow(clippy::too_many_arguments)]
9149fn handle_preview_dir_entry(
9150    root: &Path,
9151    path: &Path,
9152    name: &str,
9153    modified: String,
9154    depth: usize,
9155    parent_row_id: Option<usize>,
9156    row_id: usize,
9157    next_row_id: &mut usize,
9158    budget: &mut PreviewBudget,
9159    stats: &mut PreviewStats,
9160    rows: &mut Vec<PreviewRow>,
9161    languages: &mut Vec<&'static str>,
9162    include_patterns: &[String],
9163    exclude_patterns: &[String],
9164) -> Result<()> {
9165    let relative = preview_relative_path(root, path);
9166    if should_skip_preview_directory(&relative, exclude_patterns) {
9167        return Ok(());
9168    }
9169    stats.directories += 1;
9170    rows.push(PreviewRow {
9171        row_id,
9172        parent_row_id,
9173        depth: depth + 1,
9174        name: format!("{name}/"),
9175        kind: PreviewKind::Dir,
9176        is_dir: true,
9177        language: None,
9178        modified,
9179        type_label: "Directory".to_string(),
9180    });
9181    budget.shown += 1;
9182    if !matches!(name, ".git" | "node_modules" | "target") {
9183        collect_preview_rows(
9184            root,
9185            path,
9186            depth + 1,
9187            Some(row_id),
9188            next_row_id,
9189            budget,
9190            stats,
9191            rows,
9192            languages,
9193            include_patterns,
9194            exclude_patterns,
9195        )?;
9196    }
9197    Ok(())
9198}
9199
9200/// Handle a single file entry inside `collect_preview_rows`.
9201#[allow(clippy::too_many_arguments)]
9202fn handle_preview_file_entry(
9203    root: &Path,
9204    path: &Path,
9205    name: &str,
9206    modified: String,
9207    depth: usize,
9208    parent_row_id: Option<usize>,
9209    row_id: usize,
9210    budget: &mut PreviewBudget,
9211    stats: &mut PreviewStats,
9212    rows: &mut Vec<PreviewRow>,
9213    languages: &mut Vec<&'static str>,
9214    include_patterns: &[String],
9215    exclude_patterns: &[String],
9216) {
9217    let relative = preview_relative_path(root, path);
9218    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
9219        return;
9220    }
9221    stats.files += 1;
9222    let kind = classify_preview_file(name);
9223    match kind {
9224        PreviewKind::Supported => stats.supported += 1,
9225        PreviewKind::Skipped => stats.skipped += 1,
9226        PreviewKind::Unsupported => stats.unsupported += 1,
9227        PreviewKind::Dir => {}
9228    }
9229    let language = detect_language_name(name);
9230    if let Some(lang) = language {
9231        if !languages.contains(&lang) {
9232            languages.push(lang);
9233        }
9234    }
9235    rows.push(PreviewRow {
9236        row_id,
9237        parent_row_id,
9238        depth: depth + 1,
9239        name: name.to_owned(),
9240        kind,
9241        is_dir: false,
9242        language,
9243        modified,
9244        type_label: preview_type_label(name, language, kind),
9245    });
9246    budget.shown += 1;
9247}
9248
9249#[allow(clippy::too_many_arguments)]
9250#[allow(clippy::too_many_lines)]
9251fn collect_preview_rows(
9252    // NOSONAR(rust:S3776)
9253    root: &Path,
9254    dir: &Path,
9255    depth: usize,
9256    parent_row_id: Option<usize>,
9257    next_row_id: &mut usize,
9258    budget: &mut PreviewBudget,
9259    stats: &mut PreviewStats,
9260    rows: &mut Vec<PreviewRow>,
9261    languages: &mut Vec<&'static str>,
9262    include_patterns: &[String],
9263    exclude_patterns: &[String],
9264) -> Result<()> {
9265    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
9266        return Ok(());
9267    }
9268
9269    let mut entries = fs::read_dir(dir)
9270        .with_context(|| format!("failed to read directory {}", dir.display()))?
9271        .filter_map(std::result::Result::ok)
9272        .collect::<Vec<_>>();
9273    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
9274
9275    for entry in entries {
9276        if budget.shown >= budget.max_entries {
9277            break;
9278        }
9279
9280        let path = entry.path();
9281        let name = entry.file_name().to_string_lossy().into_owned();
9282        let Ok(metadata) = entry.metadata() else {
9283            continue;
9284        };
9285        let row_id = *next_row_id;
9286        *next_row_id += 1;
9287        let modified = metadata
9288            .modified()
9289            .ok()
9290            .map_or_else(|| "-".to_string(), format_system_time);
9291
9292        if metadata.is_dir() {
9293            handle_preview_dir_entry(
9294                root,
9295                &path,
9296                &name,
9297                modified,
9298                depth,
9299                parent_row_id,
9300                row_id,
9301                next_row_id,
9302                budget,
9303                stats,
9304                rows,
9305                languages,
9306                include_patterns,
9307                exclude_patterns,
9308            )?;
9309            continue;
9310        }
9311
9312        if metadata.is_file() {
9313            handle_preview_file_entry(
9314                root,
9315                &path,
9316                &name,
9317                modified,
9318                depth,
9319                parent_row_id,
9320                row_id,
9321                budget,
9322                stats,
9323                rows,
9324                languages,
9325                include_patterns,
9326                exclude_patterns,
9327            );
9328        }
9329    }
9330
9331    Ok(())
9332}
9333
9334fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
9335    if let Some(language) = language {
9336        return format!("{language} source");
9337    }
9338    let lower = name.to_ascii_lowercase();
9339    let ext = Path::new(&lower)
9340        .extension()
9341        .and_then(|e| e.to_str())
9342        .unwrap_or("");
9343    match kind {
9344        PreviewKind::Skipped => {
9345            if lower.ends_with(".min.js") {
9346                "Minified asset".to_string()
9347            } else if [
9348                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
9349            ]
9350            .contains(&ext)
9351            {
9352                "Binary or archive".to_string()
9353            } else {
9354                "Skipped file".to_string()
9355            }
9356        }
9357        PreviewKind::Unsupported => {
9358            if ext.is_empty() {
9359                "Unsupported file".to_string()
9360            } else {
9361                format!("{} file", ext.to_ascii_uppercase())
9362            }
9363        }
9364        PreviewKind::Supported => "Supported source".to_string(),
9365        PreviewKind::Dir => "Directory".to_string(),
9366    }
9367}
9368
9369fn format_system_time(time: SystemTime) -> String {
9370    #[allow(clippy::cast_possible_wrap)]
9371    let secs = match time.duration_since(UNIX_EPOCH) {
9372        Ok(duration) => duration.as_secs() as i64,
9373        Err(_) => return "-".to_string(),
9374    };
9375    let days = secs.div_euclid(86_400);
9376    let secs_of_day = secs.rem_euclid(86_400);
9377    let (year, month, day) = civil_from_days(days);
9378    let hour = secs_of_day / 3_600;
9379    let minute = (secs_of_day % 3_600) / 60;
9380    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
9381}
9382
9383#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9384fn civil_from_days(days: i64) -> (i32, u32, u32) {
9385    let z = days + 719_468;
9386    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
9387    let doe = z - era * 146_097;
9388    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
9389    let y = yoe + era * 400;
9390    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
9391    let mp = (5 * doy + 2) / 153;
9392    let d = doy - (153 * mp + 2) / 5 + 1;
9393    let m = mp + if mp < 10 { 3 } else { -9 };
9394    let year = y + i64::from(m <= 2);
9395    (year as i32, m as u32, d as u32)
9396}
9397
9398// The input is already lowercased via `to_ascii_lowercase()` before calling
9399// `ends_with`, so the comparisons are inherently case-insensitive.
9400#[allow(clippy::case_sensitive_file_extension_comparisons)]
9401fn detect_language_name(name: &str) -> Option<&'static str> {
9402    let lower = name.to_ascii_lowercase();
9403    if lower.ends_with(".c") || lower.ends_with(".h") {
9404        Some("C")
9405    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
9406        .iter()
9407        .any(|s| lower.ends_with(s))
9408    {
9409        Some("C++")
9410    } else if lower.ends_with(".cs") {
9411        Some("C#")
9412    } else if lower.ends_with(".py") {
9413        Some("Python")
9414    } else if lower.ends_with(".sh") {
9415        Some("Shell")
9416    } else if [".ps1", ".psm1", ".psd1"]
9417        .iter()
9418        .any(|s| lower.ends_with(s))
9419    {
9420        Some("PowerShell")
9421    } else {
9422        None
9423    }
9424}
9425
9426fn language_icon_file(language: &str) -> Option<&'static str> {
9427    match language {
9428        "C" => Some("c.png"),
9429        "C++" => Some("cpp.png"),
9430        "C#" => Some("c-sharp.png"),
9431        "Python" => Some("python.png"),
9432        "Shell" => Some("shell.png"),
9433        "PowerShell" => Some("powershell.png"),
9434        "JavaScript" => Some("java-script.png"),
9435        "HTML" => Some("html-5.png"),
9436        "Java" => Some("java.png"),
9437        "Visual Basic" => Some("visual-basic.png"),
9438        "Assembly" => Some("asm.png"),
9439        "Go" => Some("go.png"),
9440        "R" => Some("r.png"),
9441        "XML" => Some("xml.png"),
9442        "Groovy" => Some("groovy.png"),
9443        "Dockerfile" => Some("docker.png"),
9444        "Makefile" => Some("makefile.svg"),
9445        "Perl" => Some("perl.svg"),
9446        _ => None,
9447    }
9448}
9449
9450// Inline SVG badges for languages that have no PNG icon in images/icons/.
9451// Using inline SVG keeps the web UI fully self-contained — no extra files
9452// needed on disk, no 404s on air-gapped deployments.
9453// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
9454fn language_inline_svg(language: &str) -> Option<&'static str> {
9455    match language {
9456        "Rust" => Some(
9457            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>"##,
9458        ),
9459        "TypeScript" => Some(
9460            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>"##,
9461        ),
9462        _ => None,
9463    }
9464}
9465
9466// The input is already lowercased via `to_ascii_lowercase()` before the
9467// `ends_with` calls, so these comparisons are inherently case-insensitive.
9468#[allow(clippy::case_sensitive_file_extension_comparisons)]
9469fn classify_preview_file(name: &str) -> PreviewKind {
9470    let lower = name.to_ascii_lowercase();
9471
9472    let scannable = [
9473        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
9474        ".psm1", ".psd1",
9475    ]
9476    .iter()
9477    .any(|suffix| lower.ends_with(suffix));
9478
9479    if scannable {
9480        PreviewKind::Supported
9481    } else if lower.ends_with(".min.js")
9482        || lower.ends_with(".lock")
9483        || lower.ends_with(".png")
9484        || lower.ends_with(".jpg")
9485        || lower.ends_with(".jpeg")
9486        || lower.ends_with(".gif")
9487        || lower.ends_with(".zip")
9488        || lower.ends_with(".pdf")
9489        || lower.ends_with(".pyc")
9490        || lower.ends_with(".xz")
9491        || lower.ends_with(".tar")
9492        || lower.ends_with(".gz")
9493    {
9494        PreviewKind::Skipped
9495    } else {
9496        PreviewKind::Unsupported
9497    }
9498}
9499
9500fn preview_relative_path(root: &Path, path: &Path) -> String {
9501    path.strip_prefix(root)
9502        .ok()
9503        .unwrap_or(path)
9504        .to_string_lossy()
9505        .replace('\\', "/")
9506        .trim_matches('/')
9507        .to_string()
9508}
9509
9510fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
9511    if relative.is_empty() {
9512        return false;
9513    }
9514
9515    exclude_patterns.iter().any(|pattern| {
9516        wildcard_match(pattern, relative)
9517            || wildcard_match(pattern, &format!("{relative}/"))
9518            || wildcard_match(pattern, &format!("{relative}/placeholder"))
9519    })
9520}
9521
9522fn should_include_preview_file(
9523    relative: &str,
9524    include_patterns: &[String],
9525    exclude_patterns: &[String],
9526) -> bool {
9527    if relative.is_empty() {
9528        return true;
9529    }
9530
9531    let included = include_patterns.is_empty()
9532        || include_patterns
9533            .iter()
9534            .any(|pattern| wildcard_match(pattern, relative));
9535    let excluded = exclude_patterns
9536        .iter()
9537        .any(|pattern| wildcard_match(pattern, relative));
9538
9539    included && !excluded
9540}
9541
9542fn wildcard_match(pattern: &str, candidate: &str) -> bool {
9543    let pattern = pattern.trim().replace('\\', "/");
9544    let candidate = candidate.trim().replace('\\', "/");
9545    let p = pattern.as_bytes();
9546    let c = candidate.as_bytes();
9547    let mut pi = 0usize;
9548    let mut ci = 0usize;
9549    let mut star: Option<usize> = None;
9550    let mut star_match = 0usize;
9551
9552    while ci < c.len() {
9553        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
9554            pi += 1;
9555            ci += 1;
9556        } else if pi < p.len() && p[pi] == b'*' {
9557            while pi < p.len() && p[pi] == b'*' {
9558                pi += 1;
9559            }
9560            star = Some(pi);
9561            star_match = ci;
9562        } else if let Some(star_pi) = star {
9563            star_match += 1;
9564            ci = star_match;
9565            pi = star_pi;
9566        } else {
9567            return false;
9568        }
9569    }
9570
9571    while pi < p.len() && p[pi] == b'*' {
9572        pi += 1;
9573    }
9574
9575    pi == p.len()
9576}
9577
9578fn escape_html(value: &str) -> String {
9579    value
9580        .replace('&', "&amp;")
9581        .replace('<', "&lt;")
9582        .replace('>', "&gt;")
9583        .replace('"', "&quot;")
9584        .replace('\'', "&#39;")
9585}
9586
9587#[derive(Clone)]
9588struct SubmoduleRow {
9589    name: String,
9590    relative_path: String,
9591    files_analyzed: u64,
9592    code_lines: u64,
9593    comment_lines: u64,
9594    blank_lines: u64,
9595    total_physical_lines: u64,
9596    html_url: Option<String>,
9597}
9598
9599#[derive(Template)]
9600#[template(
9601    source = r##"
9602<!doctype html>
9603<html lang="en">
9604<head>
9605  <meta charset="utf-8">
9606  <title>OxideSLOC | tmp-sloc</title>
9607  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9608  <style nonce="{{ csp_nonce }}">
9609    :root {
9610      --bg: #efe9e2;
9611      --surface: #fcfaf7;
9612      --surface-2: #f7f0e8;
9613      --surface-3: #efe3d5;
9614      --line: #dfcfbf;
9615      --line-strong: #cfb29c;
9616      --text: #2f241c;
9617      --muted: #6f6257;
9618      --muted-2: #917f71;
9619      --nav: #b85d33;
9620      --nav-2: #7a371b;
9621      --accent: #2563eb;
9622      --accent-2: #1d4ed8;
9623      --oxide: #b85d33;
9624      --oxide-2: #8f4220;
9625      --success-bg: #eaf9ee;
9626      --success-text: #1c8746;
9627      --warn-bg: #fff2d8;
9628      --warn-text: #926000;
9629      --danger-bg: #fdeaea;
9630      --danger-text: #b33b3b;
9631      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
9632      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
9633      --radius: 14px;
9634    }
9635
9636    body.dark-theme {
9637      --bg: #1b1511;
9638      --surface: #261c17;
9639      --surface-2: #2d221d;
9640      --surface-3: #372922;
9641      --line: #524238;
9642      --line-strong: #6c5649;
9643      --text: #f5ece6;
9644      --muted: #c7b7aa;
9645      --muted-2: #aa9485;
9646      --nav: #b85d33;
9647      --nav-2: #7a371b;
9648      --accent: #6f9bff;
9649      --accent-2: #4a78ee;
9650      --oxide: #d37a4c;
9651      --oxide-2: #b35428;
9652      --success-bg: #163927;
9653      --success-text: #8fe2a8;
9654      --warn-bg: #3c2d11;
9655      --warn-text: #f3cb75;
9656      --danger-bg: #3d1f1f;
9657      --danger-text: #ff9f9f;
9658      --shadow: 0 14px 28px rgba(0,0,0,0.28);
9659      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
9660    }
9661
9662    * { box-sizing: border-box; }
9663    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); }
9664    html { overflow-y: scroll; }
9665    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
9666    .top-nav, .page, .loading { position: relative; z-index: 2; }
9667    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
9668    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
9669    .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); }
9670    .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; }
9671    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
9672    .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)); }
9673    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
9674    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
9675    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
9676    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
9677    .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; }
9678    .nav-project-pill.visible { display:inline-flex; }
9679    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
9680    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
9681    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
9682    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
9683    @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; } }
9684    .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; }
9685    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
9686    .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; }
9687    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
9688    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
9689    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
9690    .theme-toggle .icon-sun { display:none; }
9691    body.dark-theme .theme-toggle .icon-sun { display:block; }
9692    body.dark-theme .theme-toggle .icon-moon { display:none; }
9693    .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;}
9694    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
9695    .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);}
9696    .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;}
9697    .settings-close:hover{color:var(--text);background:var(--surface-2);}
9698    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
9699    .settings-modal-body{padding:14px 16px 16px;}
9700    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
9701    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
9702    .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;}
9703    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
9704    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
9705    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
9706    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
9707    .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;}
9708    .tz-select:focus{border-color:var(--oxide);}
9709    .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; }
9710    .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;}
9711    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
9712    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
9713    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
9714    .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; }
9715    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
9716    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
9717    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
9718    .wb-stats-header { padding: 10px 24px 0; }
9719    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
9720    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
9721    .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; }
9722    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9723    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9724    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9725    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
9726    .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; }
9727    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
9728    .ws-stat-analyzers { position: relative; }
9729    .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; }
9730    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
9731    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
9732    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
9733    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
9734    .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; }
9735    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
9736    .ws-divider { display: none; }
9737    .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%; }
9738    .ws-path-link:hover { color:var(--oxide); }
9739    body.dark-theme .ws-path-link { color:var(--oxide); }
9740    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
9741    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9742    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
9743    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9744    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
9745    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
9746    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
9747    .ws-mini-box-lg { flex:2 1 0; }
9748    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
9749    .ws-mini-box-br { flex:1.5 1 0; }
9750    .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); }
9751    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
9752    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
9753    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
9754    .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; }
9755    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
9756    .git-source-banner strong { font-weight:800; color:var(--text); }
9757    .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; }
9758    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
9759    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
9760    .git-source-banner a:hover { text-decoration:underline; }
9761    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
9762    .path-scope-sep { background:var(--line); margin:4px 14px; }
9763    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
9764    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
9765    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
9766    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
9767    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
9768    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
9769    .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; }
9770    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9771    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9772    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9773    .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; }
9774    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
9775    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
9776    [data-wb-tip] { cursor:help; }
9777    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
9778    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
9779    .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; }
9780    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
9781    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
9782    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
9783    .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; }
9784    .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); }
9785    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
9786    .side-info-card { padding: 18px; }
9787    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
9788    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
9789    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
9790    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
9791    .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); }
9792    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
9793    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9794    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
9795    .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; }
9796    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:start; min-height: calc(100vh - 57px); }
9797    .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; }
9798    .side-stack::-webkit-scrollbar { display: none; }
9799    .step-nav { padding: 20px 16px; }
9800    .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); }
9801    .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; }
9802    .step-button:hover { background: var(--surface-2); }
9803    .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); }
9804    .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; }
9805    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
9806    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
9807    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
9808    .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); }
9809    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
9810    .step-nav-sum-row:last-child { border-bottom:none; }
9811    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
9812    .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; }
9813    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
9814    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
9815    .quick-scan-section { padding: 10px 4px 14px; }
9816    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
9817    .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; }
9818    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
9819    .quick-scan-btn:active { transform:translateY(0); }
9820    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
9821    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
9822    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
9823    @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);} }
9824    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
9825    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
9826    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
9827    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
9828    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
9829    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
9830    .step-button.done .step-check { opacity:1; }
9831    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
9832    .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; }
9833    .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; }
9834    .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; }
9835    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
9836    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
9837    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
9838    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
9839    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
9840    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
9841    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
9842    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
9843    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
9844    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
9845    .card-body { padding: 22px; }
9846    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
9847    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
9848    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
9849    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
9850    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
9851    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
9852    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
9853    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
9854    .field { min-width:0; }
9855    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
9856    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; }
9857    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); }
9858    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
9859    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); }
9860    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
9861    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9862    .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; }
9863    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
9864    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
9865    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
9866    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
9867    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
9868    .input-group.compact { grid-template-columns: 1fr auto auto; }
9869    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
9870    .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)); }
9871    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
9872    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
9873    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
9874    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
9875    .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; }
9876    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
9877    .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; }
9878    .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); }
9879    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
9880    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
9881    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
9882    button.secondary { background: var(--surface); }
9883    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9884    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
9885    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
9886    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9887    .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); }
9888    .section + .wizard-actions { border-top: none; padding-top: 0; }
9889    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
9890    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9891    .field-help-grid.coupled-help { margin-top: 12px; }
9892    .field-help-grid.preset-grid { align-items: start; }
9893    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
9894    .preset-inline-row .field { margin: 0; }
9895    .preset-inline-row .explainer-card { margin: 0; }
9896    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
9897    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
9898    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
9899    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
9900    .preset-kv-row > :last-child { flex:1; min-width:0; }
9901    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
9902    .output-field-row .field { margin: 0; }
9903    .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; }
9904    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
9905    .step3-subtitle { margin-bottom: 10px; max-width: none; }
9906    .counting-intro { margin-bottom: 8px; max-width: none; }
9907    .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; }
9908    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
9909    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
9910    .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; }
9911    .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; }
9912    .section-spacer-top { margin-top: 28px; }
9913    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
9914    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
9915    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
9916    .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); }
9917    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
9918    .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; }
9919    .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; }
9920    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
9921    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9922    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
9923    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
9924    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
9925    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
9926    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
9927    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
9928    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
9929    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
9930    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
9931    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
9932    .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); }
9933    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
9934    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
9935    .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; }
9936    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
9937    .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; }
9938    .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; }
9939    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
9940    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
9941    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
9942    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
9943    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
9944    .advanced-rule-description strong { color: var(--text); }
9945    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
9946    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
9947    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
9948    .review-link:hover { text-decoration: underline; }
9949    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
9950    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
9951    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
9952    .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; }
9953    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
9954    .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
9955    .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
9956    body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
9957    .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
9958    body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
9959    .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; }
9960    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
9961    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
9962    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
9963    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9964    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
9965    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
9966    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
9967    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
9968    .review-card ul { padding-left: 18px; margin: 0; }
9969    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
9970    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
9971    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
9972    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
9973    .review-card { min-height: 200px; }
9974    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
9975    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
9976    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
9977    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
9978    .lang-overflow-chip { position:relative; cursor:default; }
9979    .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; }
9980    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
9981    .git-inline-row { align-items:start; }
9982    .mixed-line-card { display:flex; flex-direction:column; }
9983    .preset-inline-row .toggle-card { justify-content: center; }
9984        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
9985    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
9986    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
9987    .explorer-title { font-size: 18px; font-weight: 850; }
9988    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
9989    .explorer-subtitle.wide { max-width: none; }
9990    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
9991    .better-spacing { align-items:flex-start; justify-content:flex-end; }
9992    .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; }
9993    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
9994    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
9995    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
9996    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
9997    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
9998    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
9999    .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; }
10000    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
10001    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
10002    .scope-stat-button.supported { background: var(--success-bg); }
10003    .scope-stat-button.skipped { background: var(--warn-bg); }
10004    .scope-stat-button.unsupported { background: var(--danger-bg); }
10005    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
10006    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
10007    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
10008    [data-tooltip] { position: relative; }
10009    [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); }
10010    [data-tooltip]:hover::after { display: block; }
10011    .scope-stat-button[data-tooltip] { cursor: pointer; }
10012    .badge[data-tooltip] { cursor: help; }
10013    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
10014    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
10015    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
10016    .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; }
10017    .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; }
10018    code { display:inline-block; margin-top:0; padding:2px 7px; }
10019    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
10020    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
10021    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
10022    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
10023    .language-pill.muted-pill { color: var(--muted); }
10024    button.language-pill { appearance:none; cursor:pointer; }
10025    .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); }
10026    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
10027    .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; }
10028    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
10029    .file-explorer-search-row { margin-left: auto; }
10030    .explorer-filter-select { min-width: 170px; width: 170px; }
10031    .explorer-search { min-width: 300px; width: 300px; }
10032    .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); }
10033    .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; }
10034    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
10035    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
10036    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
10037    .file-explorer-tree { max-height: 640px; overflow:auto; }
10038    .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); }
10039    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
10040    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
10041    .tree-row.hidden-by-filter { display:none !important; }
10042    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
10043    .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; }
10044    .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; }
10045    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
10046    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
10047    .tree-node { display:inline-flex; align-items:center; min-width:0; }
10048    .tree-node-dir { color: var(--text); font-weight: 800; }
10049    .tree-node-supported { color: var(--success-text); }
10050    .tree-node-skipped { color: var(--warn-text); }
10051    .tree-node-unsupported { color: var(--danger-text); }
10052    .tree-node-more { color: var(--muted-2); font-style: italic; }
10053    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
10054    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
10055    .tree-status-cell { display:flex; justify-content:flex-start; }
10056    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
10057    .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; }
10058    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
10059    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
10060    .cov-scan-idle { display:none; }
10061    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
10062    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
10063    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
10064    .cov-scan-title { font-weight:600; font-size:12.5px; }
10065    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
10066    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
10067    .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; }
10068    .cov-scan-use:hover { opacity:.75; }
10069    .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; }
10070    .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; }
10071    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
10072    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
10073    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
10074    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
10075    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
10076    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
10077    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
10078    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
10079    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
10080    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
10081    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
10082    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
10083    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
10084    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
10085    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
10086    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
10087    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
10088    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
10089    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
10090    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
10091    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
10092    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
10093    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
10094    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
10095    .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); }
10096    .loading.active { display:flex; }
10097    .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; }
10098    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
10099    .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; }
10100    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
10101    .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; }
10102    .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; }
10103    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
10104    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
10105    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
10106    .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; }
10107    .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
10108    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
10109    .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
10110    .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
10111    .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; }
10112    .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; }
10113    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
10114    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
10115    .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; }
10116    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
10117    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
10118    .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; }
10119    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
10120    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
10121    .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; }
10122    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
10123    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
10124    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
10125    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
10126    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
10127    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
10128    .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; }
10129    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
10130    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
10131    .hidden { display:none !important; }
10132    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
10133    .site-footer a{color:var(--muted);}
10134    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
10135    @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; } }
10136    .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;}
10137    @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));}}
10138    .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;}
10139    .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; }
10140    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
10141    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
10142    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
10143    .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; }
10144    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
10145    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
10146    .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; }
10147    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
10148    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
10149    .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; }
10150    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
10151    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
10152    .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; }
10153    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
10154    .info-icon-btn:hover { color:var(--text); }
10155    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); }
10156    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
10157    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
10158  </style>
10159</head>
10160<body>
10161  <div class="background-watermarks" aria-hidden="true">
10162    <img src="/images/logo/logo-text.png" alt="" />
10163    <img src="/images/logo/logo-text.png" alt="" />
10164    <img src="/images/logo/logo-text.png" alt="" />
10165    <img src="/images/logo/logo-text.png" alt="" />
10166    <img src="/images/logo/logo-text.png" alt="" />
10167    <img src="/images/logo/logo-text.png" alt="" />
10168    <img src="/images/logo/logo-text.png" alt="" />
10169    <img src="/images/logo/logo-text.png" alt="" />
10170    <img src="/images/logo/logo-text.png" alt="" />
10171    <img src="/images/logo/logo-text.png" alt="" />
10172    <img src="/images/logo/logo-text.png" alt="" />
10173    <img src="/images/logo/logo-text.png" alt="" />
10174    <img src="/images/logo/logo-text.png" alt="" />
10175    <img src="/images/logo/logo-text.png" alt="" />
10176  </div>
10177  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10178  <div class="top-nav">
10179    <div class="top-nav-inner">
10180      <a class="brand" href="/">
10181        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
10182        <div class="brand-copy">
10183          <div class="brand-title">OxideSLOC</div>
10184          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
10185        </div>
10186      </a>
10187      <div class="nav-project-slot">
10188        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
10189          <span class="nav-project-label">Project</span>
10190          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
10191        </div>
10192      </div>
10193      <div class="nav-status">
10194        <a class="nav-pill" href="/">Home</a>
10195        <div class="nav-dropdown">
10196          <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>
10197          <div class="nav-dropdown-menu">
10198            <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>
10199          </div>
10200        </div>
10201        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10202        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10203        <div class="nav-dropdown">
10204          <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>
10205          <div class="nav-dropdown-menu">
10206            <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>
10207          </div>
10208        </div>
10209        <div class="server-status-wrap">
10210          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
10211          <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>
10212        </div>
10213        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10214          <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>
10215        </button>
10216        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
10217          <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>
10218          <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>
10219        </button>
10220      </div>
10221    </div>
10222  </div>
10223
10224  <div class="loading" id="loading">
10225    <div class="loading-card">
10226      <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
10227      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
10228      <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
10229      <div class="lc-path" id="lc-path"></div>
10230      <div class="lc-metrics" id="lc-metrics">
10231        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
10232        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
10233      </div>
10234      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
10235      <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>
10236      <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>
10237      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
10238      <div class="lc-actions hidden" id="lc-actions">
10239        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
10240        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
10241      </div>
10242      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
10243        <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>
10244        Cancel scan
10245      </button>
10246    </div>
10247  </div>
10248
10249  <div class="page">
10250    <div class="workbench-strip">
10251      <div class="workbench-box wb-stats">
10252        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
10253          <span class="wb-stats-title">Analysis session</span>
10254        </div>
10255        <div class="ws-left">
10256          <div class="ws-stat ws-stat-analyzers">
10257            <span class="ws-label">Analyzers</span>
10258            <span class="ws-value">
10259              <span class="ws-badge">41 languages</span>
10260            </span>
10261            <div class="ws-lang-tooltip">
10262              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
10263              <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>
10264              <div class="ws-lang-grid">
10265                <span class="ws-lang-item">Assembly</span>
10266                <span class="ws-lang-item">C</span>
10267                <span class="ws-lang-item">C++</span>
10268                <span class="ws-lang-item">C#</span>
10269                <span class="ws-lang-item">Clojure</span>
10270                <span class="ws-lang-item">CSS</span>
10271                <span class="ws-lang-item">Dart</span>
10272                <span class="ws-lang-item">Dockerfile</span>
10273                <span class="ws-lang-item">Elixir</span>
10274                <span class="ws-lang-item">Erlang</span>
10275                <span class="ws-lang-item">F#</span>
10276                <span class="ws-lang-item">Go</span>
10277                <span class="ws-lang-item">Groovy</span>
10278                <span class="ws-lang-item">Haskell</span>
10279                <span class="ws-lang-item">HTML</span>
10280                <span class="ws-lang-item">Java</span>
10281                <span class="ws-lang-item">JavaScript</span>
10282                <span class="ws-lang-item">Julia</span>
10283                <span class="ws-lang-item">Kotlin</span>
10284                <span class="ws-lang-item">Lua</span>
10285                <span class="ws-lang-item">Makefile</span>
10286                <span class="ws-lang-item">Nim</span>
10287                <span class="ws-lang-item">Obj-C</span>
10288                <span class="ws-lang-item">OCaml</span>
10289                <span class="ws-lang-item">Perl</span>
10290                <span class="ws-lang-item">PHP</span>
10291                <span class="ws-lang-item">PowerShell</span>
10292                <span class="ws-lang-item">Python</span>
10293                <span class="ws-lang-item">R</span>
10294                <span class="ws-lang-item">Ruby</span>
10295                <span class="ws-lang-item">Rust</span>
10296                <span class="ws-lang-item">Scala</span>
10297                <span class="ws-lang-item">SCSS</span>
10298                <span class="ws-lang-item">Shell</span>
10299                <span class="ws-lang-item">SQL</span>
10300                <span class="ws-lang-item">Svelte</span>
10301                <span class="ws-lang-item">Swift</span>
10302                <span class="ws-lang-item">TypeScript</span>
10303                <span class="ws-lang-item">Vue</span>
10304                <span class="ws-lang-item">XML</span>
10305                <span class="ws-lang-item">Zig</span>
10306              </div>
10307            </div>
10308          </div>
10309          <div class="ws-divider"></div>
10310          <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>
10311          <div class="ws-divider"></div>
10312          <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>
10313          <div class="ws-divider"></div>
10314          <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.">
10315            <span class="ws-label">Output</span>
10316            <span class="ws-value">
10317              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
10318                <span id="ws-output-root">project/sloc</span>
10319              </button>
10320            </span>
10321          </div>
10322        </div>
10323      </div>
10324      <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.">
10325        <div class="ws-history-label">Scan history</div>
10326        <div class="ws-history-inner">
10327          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
10328            <div class="ws-mini-label">Scans</div>
10329            <div class="ws-mini-value" id="ws-scan-count">—</div>
10330          </div>
10331          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
10332            <div class="ws-mini-label">Last Scan</div>
10333            <div class="ws-mini-value" id="ws-last-scan">—</div>
10334          </div>
10335          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
10336            <div class="ws-mini-label">Branch</div>
10337            <div class="ws-mini-value" id="ws-branch">—</div>
10338          </div>
10339        </div>
10340      </div>
10341    </div>
10342
10343    <div class="layout">
10344      <aside class="side-stack">
10345        <section class="step-nav">
10346        <h3>Guided scan setup</h3>
10347        <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>
10348        <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>
10349        <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>
10350        <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>
10351
10352        <div class="step-steps-divider"></div>
10353
10354        <div class="step-nav-info" id="step-nav-info">
10355          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
10356          <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>
10357        </div>
10358
10359        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
10360          <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>
10361          <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>
10362          <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>
10363        </div>
10364
10365        <div class="quick-scan-divider"></div>
10366        <div class="quick-scan-section">
10367          <div class="quick-scan-label">No customization needed?</div>
10368          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
10369            <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>
10370            Quick Scan
10371          </button>
10372          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
10373        </div>
10374
10375        <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>
10376        </section>
10377
10378      </aside>
10379
10380      <section class="card">
10381        <div class="card-header">
10382          <div class="card-title-row">
10383            <div>
10384              <h1 class="card-title">Guided scan configuration</h1>
10385              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
10386            </div>
10387            <div class="wizard-progress" aria-label="Scan setup progress">
10388              <div class="wizard-progress-top">
10389                <span class="wizard-progress-label">Setup progress</span>
10390                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
10391              </div>
10392              <div class="wizard-progress-track">
10393                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
10394              </div>
10395            </div>
10396          </div>
10397        </div>
10398        <div class="card-body">
10399          <form method="post" action="/analyze" id="analyze-form">
10400            <div class="wizard-step active" data-step="1">
10401              <div class="section">
10402                <div class="section-kicker">Step 1</div>
10403                <h2>Select project and preview scope</h2>
10404                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
10405                <div class="field">
10406                  <label for="path">Project path</label>
10407                  {% if !git_repo.is_empty() %}
10408                  <div class="git-source-banner">
10409                    <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>
10410                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
10411                    <a href="/git-browser">← Back to Git Browser</a>
10412                  </div>
10413                  {% endif %}
10414                  <div class="path-scope-grid">
10415                      {% if !git_repo.is_empty() %}
10416                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
10417                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
10418                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
10419                      {% else %}
10420                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
10421                      <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
10422                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
10423                      {% endif %}
10424                    <div class="path-scope-sep"></div>
10425                    <div class="scope-legend-row">
10426                      <span class="scope-legend-label">Scope legend:</span>
10427                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
10428                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
10429                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
10430                    </div>
10431                  </div>
10432                  {% if git_repo.is_empty() %}
10433                  <div class="path-info-row">
10434                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
10435                      <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>
10436                      <span id="project-size-text">Project size: —</span>
10437                    </button>
10438                  </div>
10439                  {% else %}
10440                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
10441                  {% endif %}
10442                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
10443                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
10444                </div>
10445
10446                <div class="scope-preview-divider" aria-hidden="true"></div>
10447
10448                <div id="preview-panel">
10449                  <div class="preview-error">Loading preview...</div>
10450                </div>
10451              </div>
10452
10453              <div class="section" style="margin-top:14px;">
10454                <div class="preset-inline-row git-inline-row">
10455                  <div class="toggle-card" style="margin:0;">
10456                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
10457                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
10458                    <label class="checkbox">
10459                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
10460                      <div>
10461                        <span>Detect and separate git submodules</span>
10462                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
10463                      </div>
10464                    </label>
10465                  </div>
10466                  <div class="explainer-card prominent" style="margin:0;">
10467                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10468                    <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>
10469                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
10470    path = libs/core
10471    url  = https://github.com/org/core.git
10472
10473[submodule "libs/ui"]
10474    path = libs/ui
10475    url  = https://github.com/org/ui.git</div>
10476                  </div>
10477                </div>
10478              </div>
10479
10480              <div class="section">
10481                <div class="field-grid">
10482                  <div class="field">
10483                    <label for="include_globs">Include globs</label>
10484                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
10485                    <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>
10486                  </div>
10487                  <div class="field">
10488                    <label for="exclude_globs">Exclude globs</label>
10489                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
10490                    <div id="quick-exclude-chips" class="quick-excl-row">
10491                      <span class="quick-excl-label">Quick add:</span>
10492                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
10493                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
10494                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
10495                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
10496                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
10497                      <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>
10498                    </div>
10499                    <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>
10500                  </div>
10501                </div>
10502                <div class="glob-guidance-grid">
10503                  <div class="glob-guidance-card">
10504                    <strong>How to read them</strong>
10505                    <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>
10506                  </div>
10507                  <div class="glob-guidance-card">
10508                    <strong>Common include examples</strong>
10509                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
10510                  </div>
10511                  <div class="glob-guidance-card">
10512                    <strong>Common exclude examples</strong>
10513                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
10514                  </div>
10515                </div>
10516              </div>
10517
10518              <div class="section" style="margin-top:14px;">
10519                <div class="preset-inline-row git-inline-row">
10520                  <div class="toggle-card" style="margin:0;">
10521                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
10522                    <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>
10523                    <div class="field" style="margin:0;">
10524                      <div class="input-group compact">
10525                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
10526                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
10527                      </div>
10528                      <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>
10529                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
10530                    </div>
10531                  </div>
10532                  <div class="explainer-card prominent" style="margin:0;">
10533                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10534                    <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>
10535                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
10536lcov --capture --directory . --output-file coverage/lcov.info
10537
10538# C / C++ — llvm-cov (LCOV)
10539llvm-profdata merge -sparse default.profraw -o default.profdata
10540llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
10541
10542# C# — coverlet (Cobertura XML)
10543dotnet test --collect:"XPlat Code Coverage"
10544
10545# Python — pytest-cov (Cobertura XML)
10546pytest --cov --cov-report=xml
10547
10548# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
10549./gradlew jacocoTestReport</div>
10550                  </div>
10551                </div>
10552              </div>
10553
10554              <div class="wizard-actions">
10555                <div class="left"></div>
10556                <div class="right">
10557                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
10558                </div>
10559              </div>
10560            </div>
10561
10562            <div class="wizard-step" data-step="2">
10563              <div class="section">
10564                <div class="section-kicker">Step 2</div>
10565                <h2>Choose counting behavior</h2>
10566                <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>
10567                <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
10568                <div class="subsection-bar">Primary line classification</div>
10569                <div class="preset-kv-row">
10570                  <div class="toggle-card mixed-line-card" style="margin:0;">
10571                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
10572                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
10573                    <select id="mixed_line_policy" name="mixed_line_policy">
10574                      <option value="code_only">Code only</option>
10575                      <option value="code_and_comment">Code and comment</option>
10576                      <option value="comment_only">Comment only</option>
10577                      <option value="separate_mixed_category">Separate mixed category</option>
10578                    </select>
10579                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
10580                  </div>
10581                  <div class="explainer-card prominent" style="margin:0;">
10582                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
10583                    <div class="explainer-body" id="mixed-policy-description"></div>
10584                    <div class="code-sample" id="mixed-policy-example"></div>
10585                  </div>
10586                </div>
10587              </div>
10588
10589              <div class="subsection-bar">Additional scan rules</div>
10590              <div class="scan-rules-grid">
10591                <div class="preset-inline-row">
10592                  <div class="toggle-card" style="margin:0;">
10593                    <div class="field-help-title">Generated files</div>
10594                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
10595                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10596                  </div>
10597                  <div class="explainer-card prominent" style="margin:0;">
10598                    <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>
10599                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
10600# Files matching codegen patterns are excluded:
10601#   *.generated.cs  *.pb.go  *.g.dart</div>
10602                  </div>
10603                </div>
10604                <div class="preset-inline-row">
10605                  <div class="toggle-card" style="margin:0;">
10606                    <div class="field-help-title">Minified files</div>
10607                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
10608                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10609                  </div>
10610                  <div class="explainer-card prominent" style="margin:0;">
10611                    <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>
10612                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
10613# Heuristic: very long lines + low whitespace ratio
10614#   jquery.min.js  bundle.min.css  → skipped</div>
10615                  </div>
10616                </div>
10617                <div class="preset-inline-row">
10618                  <div class="toggle-card" style="margin:0;">
10619                    <div class="field-help-title">Vendor directories</div>
10620                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
10621                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10622                  </div>
10623                  <div class="explainer-card prominent" style="margin:0;">
10624                    <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>
10625                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
10626# Directories named vendor/ node_modules/ third_party/
10627#   → entire subtree is excluded from totals</div>
10628                  </div>
10629                </div>
10630                <div class="preset-inline-row">
10631                  <div class="toggle-card" style="margin:0;">
10632                    <div class="field-help-title">Lockfiles and manifests</div>
10633                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
10634                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
10635                  </div>
10636                  <div class="explainer-card prominent" style="margin:0;">
10637                    <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>
10638                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
10639# Files like package-lock.json  Cargo.lock  yarn.lock
10640#   → skipped unless this is enabled</div>
10641                  </div>
10642                </div>
10643                <div class="preset-inline-row">
10644                  <div class="toggle-card" style="margin:0;">
10645                    <div class="field-help-title">Binary handling</div>
10646                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
10647                    <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>
10648                  </div>
10649                  <div class="explainer-card prominent" style="margin:0;">
10650                    <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>
10651                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
10652# Detected via long lines + low whitespace heuristic
10653#   .png  .exe  .so  → skipped silently</div>
10654                  </div>
10655                </div>
10656                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
10657                  <div class="toggle-card" style="margin:0;">
10658                    <div class="field-help-title">Python docstrings</div>
10659                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
10660                    <label class="checkbox">
10661                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
10662                      <span>Count as comment-style lines</span>
10663                    </label>
10664                  </div>
10665                  <div class="explainer-card prominent" style="margin:0;">
10666                    <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>
10667                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
10668                  </div>
10669                </div>
10670              </div>
10671              <div class="always-tracked-tip">
10672                <div class="always-tracked-tip-icon">ℹ</div>
10673                <div class="always-tracked-tip-body">
10674                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
10675                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
10676                  <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>
10677                </div>
10678              </div>
10679
10680              <div class="wizard-actions">
10681                <div class="left">
10682                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
10683                </div>
10684                <div class="right">
10685                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
10686                </div>
10687              </div>
10688            </div>
10689
10690            <div class="wizard-step" data-step="3">
10691              <div class="section">
10692                <div class="section-kicker">Step 3</div>
10693                <h2>Output and report identity</h2>
10694                <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>
10695                <div class="preset-kv-row">
10696                  <div class="toggle-card" style="margin:0;">
10697                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
10698                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
10699                    <select id="scan_preset">
10700                      <option value="balanced">Balanced local scan</option>
10701                      <option value="code_focused">Code focused</option>
10702                      <option value="comment_audit">Comment audit</option>
10703                      <option value="deep_review">Deep review</option>
10704                    </select>
10705                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
10706                  </div>
10707                  <div class="explainer-card">
10708                    <div class="field-help-title">Selected scan preset</div>
10709                    <div class="explainer-body" id="scan-preset-description"></div>
10710                    <div class="preset-summary-row" id="scan-preset-summary"></div>
10711                    <div class="code-sample" id="scan-preset-example"></div>
10712                    <div class="preset-note" id="scan-preset-note"></div>
10713                  </div>
10714                </div>
10715                <hr class="step3-separator" />
10716                <div class="preset-kv-row">
10717                  <div class="toggle-card" style="margin:0;">
10718                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
10719                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
10720                    <select id="artifact_preset">
10721                      <option value="review">Review bundle</option>
10722                      <option value="full">Full bundle</option>
10723                      <option value="html_only">HTML only</option>
10724                      <option value="machine">Machine bundle</option>
10725                    </select>
10726                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
10727                  </div>
10728                  <div class="explainer-card">
10729                    <div class="field-help-title">Selected artifact preset</div>
10730                    <div class="explainer-body" id="artifact-preset-description"></div>
10731                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
10732                    <div class="code-sample" id="artifact-preset-example"></div>
10733                  </div>
10734                </div>
10735              </div>
10736
10737              <div class="section section-spacer-top">
10738                <div class="output-field-row">
10739                  <div class="field">
10740                    <label for="output_dir">Output directory</label>
10741                    <div class="input-group compact">
10742                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
10743                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
10744                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
10745                    </div>
10746                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
10747                  </div>
10748                  <div class="output-field-aside">
10749                    <strong>Where reports land</strong>
10750                    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.
10751                  </div>
10752                </div>
10753              </div>
10754
10755              <div class="section section-spacer-top">
10756                <div class="output-field-row">
10757                  <div class="field">
10758                    <label for="report_title">Report title</label>
10759                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
10760                    <div class="hint">Appears in HTML and PDF output headers.</div>
10761                  </div>
10762                  <div class="output-field-aside">
10763                    <strong>Shown in exported artifacts</strong>
10764                    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.
10765                  </div>
10766                </div>
10767              </div>
10768
10769              <div class="section section-spacer-top">
10770                <div class="output-field-row">
10771                  <div class="field">
10772                    <label for="report_header_footer">Report header / footer</label>
10773                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
10774                    <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>
10775                  </div>
10776                  <div class="output-field-aside">
10777                    <strong>Page-level identification</strong>
10778                    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.
10779                  </div>
10780                </div>
10781              </div>
10782
10783              <div class="section">
10784                <div class="section-kicker">Artifacts</div>
10785                <div class="artifact-grid" style="margin-bottom:24px;">
10786                  <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
10787                    <div class="marker">✓</div>
10788                    <div class="artifact-icon">H</div>
10789                    <h4>HTML report</h4>
10790                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
10791                    <div class="artifact-tags">
10792                      <span class="soft-chip">Best for visual review</span>
10793                      <span class="soft-chip">Embeddable preview</span>
10794                    </div>
10795                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
10796                  </div>
10797                  <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
10798                    <div class="marker">✓</div>
10799                    <div class="artifact-icon">P</div>
10800                    <h4>PDF export</h4>
10801                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
10802                    <div class="artifact-tags">
10803                      <span class="soft-chip">Portable snapshot</span>
10804                      <span class="soft-chip">Good for handoff</span>
10805                    </div>
10806                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
10807                  </div>
10808                  <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
10809                    <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>
10810                    <div class="marker">✓</div>
10811                    <div class="artifact-icon" style="color:var(--muted);">J</div>
10812                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
10813                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
10814                    <div class="artifact-tags">
10815                      <span class="soft-chip">Required for compare</span>
10816                      <span class="soft-chip">Auto-enabled</span>
10817                    </div>
10818                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
10819                  </div>
10820                </div>
10821                <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>
10822              </div>
10823
10824              <div class="wizard-actions">
10825                <div class="left">
10826                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
10827                </div>
10828                <div class="right">
10829                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
10830                </div>
10831              </div>
10832            </div>
10833
10834            <div class="wizard-step" data-step="4">
10835              <div class="section">
10836                <div class="section-kicker">Step 4</div>
10837                <h2>Review selections and run</h2>
10838                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
10839                <div class="review-grid">
10840                  <div class="review-card highlight">
10841                    <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>
10842                    <ul id="review-scan-summary"></ul>
10843                  </div>
10844                  <div class="review-card highlight">
10845                    <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>
10846                    <ul id="review-count-summary"></ul>
10847                  </div>
10848                  <div class="review-card">
10849                    <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>
10850                    <ul id="review-artifact-summary"></ul>
10851                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
10852                  </div>
10853                  <div class="review-card">
10854                    <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>
10855                    <ul id="review-preview-summary"></ul>
10856                  </div>
10857                </div>
10858              </div>
10859
10860              <div class="wizard-actions">
10861                <div class="left">
10862                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
10863                </div>
10864                <div class="right">
10865                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
10866                </div>
10867              </div>
10868            </div></form>
10869        </div>
10870      </section>
10871    </div>
10872  </div>
10873
10874  <script nonce="{{ csp_nonce }}">
10875    (function () {
10876      function startScanPhase() {
10877        var phaseEl = document.getElementById("scan-phase");
10878        if (!phaseEl) return;
10879        var phases = [
10880          "Discovering files...",
10881          "Decoding file encodings...",
10882          "Detecting languages...",
10883          "Analyzing source lines...",
10884          "Applying counting policies...",
10885          "Aggregating results...",
10886          "Rendering report..."
10887        ];
10888        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
10889        var i = 0;
10890        function next() {
10891          phaseEl.style.opacity = "0";
10892          setTimeout(function () {
10893            phaseEl.textContent = phases[i];
10894            phaseEl.style.opacity = "0.85";
10895            var delay = durations[i] || 1800;
10896            i++;
10897            if (i < phases.length) { setTimeout(next, delay); }
10898          }, 200);
10899        }
10900        next();
10901      }
10902
10903      var form = document.getElementById("analyze-form");
10904      var loading = document.getElementById("loading");
10905      var submitButton = document.getElementById("submit-button");
10906      var pathInput = document.getElementById("path");
10907      var GIT_MODE = !!(pathInput && pathInput.readOnly);
10908      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
10909      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
10910      var outputDirInput = document.getElementById("output_dir");
10911      var reportTitleInput = document.getElementById("report_title");
10912      var previewPanel = document.getElementById("preview-panel");
10913      var refreshButton = document.getElementById("refresh-preview");
10914      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
10915      var useSamplePath = document.getElementById("use-sample-path");
10916      var useDefaultOutput = document.getElementById("use-default-output");
10917      var browsePath = document.getElementById("browse-path");
10918      var browseOutputDir = document.getElementById("browse-output-dir");
10919      var browseCoverage = document.getElementById("browse-coverage");
10920      var coverageInput = document.getElementById("coverage_file");
10921      var covScanStatus = document.getElementById("cov-scan-status");
10922      var coverageSuggestTimer = null;
10923      var covAutoFilled = false;
10924      var themeToggle = document.getElementById("theme-toggle");
10925      var mixedLinePolicy = document.getElementById("mixed_line_policy");
10926      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
10927      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
10928      var scanPreset = document.getElementById("scan_preset");
10929      var artifactPreset = document.getElementById("artifact_preset");
10930      var includeGlobsInput = document.getElementById("include_globs");
10931      var excludeGlobsInput = document.getElementById("exclude_globs");
10932
10933      // Quick-exclude chips — append pattern to exclude_globs textarea.
10934      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
10935        chip.addEventListener("click", function() {
10936          var pattern = chip.getAttribute("data-pattern") || "";
10937          if (!pattern || !excludeGlobsInput) return;
10938          var current = excludeGlobsInput.value.trim();
10939          // For the "skip all" chip, replace any existing dep patterns cleanly.
10940          var patterns = pattern.split("\n");
10941          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
10942          var added = false;
10943          patterns.forEach(function(p) {
10944            p = p.trim();
10945            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
10946          });
10947          if (added) {
10948            excludeGlobsInput.value = lines.join("\n");
10949            excludeGlobsInput.dispatchEvent(new Event("input"));
10950          }
10951          chip.classList.add("active");
10952        });
10953      });
10954
10955      var liveReportTitle = document.getElementById("live-report-title");
10956      var navProjectPill = document.getElementById("nav-project-pill");
10957      var navProjectTitle = document.getElementById("nav-project-title");
10958      var reportTitlePreview = null;
10959      var wizardProgressFill = document.getElementById("wizard-progress-fill");
10960      var wizardProgressValue = document.getElementById("wizard-progress-value");
10961      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
10962      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
10963      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
10964      var reportTitleTouched = false;
10965      var currentStep = 1;
10966      var previewTimer = null;
10967      var quickScanBtn = document.getElementById("quick-scan-btn");
10968
10969      function dismissAnalysisModal() {
10970        if (loading) loading.classList.remove("active");
10971        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10972          var el = document.getElementById(id);
10973          if (el) el.classList.add("hidden");
10974        });
10975        var cancelBtn = document.getElementById("lc-cancel-btn");
10976        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
10977        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
10978        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
10979        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10980        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10981        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10982        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10983        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10984      }
10985
10986      var lcDismissBtn = document.getElementById("lc-dismiss");
10987      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
10988
10989      function startAsyncAnalysis(formData) {
10990        var gitRepo = (formData.get("git_repo") || "").toString();
10991        var gitRef  = (formData.get("git_ref")  || "").toString();
10992        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
10993        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
10994
10995        var pathEl = document.getElementById("lc-path");
10996        if (pathEl) pathEl.textContent = displayPath;
10997
10998        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10999          var el = document.getElementById(id);
11000          if (el) el.classList.add("hidden");
11001        });
11002        var cancelBtn = document.getElementById("lc-cancel-btn");
11003        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
11004        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
11005        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
11006        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
11007        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
11008        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
11009
11010        if (loading) loading.classList.add("active");
11011
11012        var startTime = Date.now();
11013        var elapsedTimer = setInterval(function() {
11014          var s = Math.floor((Date.now() - startTime) / 1000);
11015          var el = document.getElementById("lc-elapsed");
11016          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
11017        }, 1000);
11018
11019        var warnShown = false, pollRetries = 0, activeWaitId = null;
11020
11021        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
11022
11023        function lcShowCancelled() {
11024          clearInterval(elapsedTimer);
11025          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
11026          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
11027          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
11028          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
11029          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
11030          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
11031          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
11032          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
11033          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
11034          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
11035        }
11036
11037        var lcCancelBtn = document.getElementById("lc-cancel-btn");
11038        if (lcCancelBtn) {
11039          lcCancelBtn.onclick = function() {
11040            if (!activeWaitId) { dismissAnalysisModal(); return; }
11041            lcCancelBtn.disabled = true;
11042            lcCancelBtn.textContent = "Cancelling…";
11043            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
11044              .then(function() { lcShowCancelled(); })
11045              .catch(function() { lcShowCancelled(); });
11046          };
11047        }
11048
11049        function lcShowError(msg) {
11050          clearInterval(elapsedTimer);
11051          lcSetPhase("Failed");
11052          var msgEl = document.getElementById("lc-err-msg");
11053          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
11054          var errEl = document.getElementById("lc-err");
11055          var actEl = document.getElementById("lc-actions");
11056          if (errEl) errEl.classList.remove("hidden");
11057          if (actEl) actEl.classList.remove("hidden");
11058          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
11059          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
11060        }
11061
11062        function lcPoll(waitId) {
11063          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
11064            .then(function(r) {
11065              if (!r.ok) throw new Error("HTTP " + r.status);
11066              return r.json();
11067            })
11068            .then(function(data) {
11069              pollRetries = 0;
11070              if (data.state === "complete") {
11071                clearInterval(elapsedTimer);
11072                lcSetPhase("Done");
11073                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
11074              } else if (data.state === "failed") {
11075                lcShowError(data.message);
11076              } else if (data.state === "cancelled") {
11077                lcShowCancelled();
11078              } else {
11079                var s = Math.floor((Date.now() - startTime) / 1000);
11080                if (s > 90 && !warnShown) {
11081                  warnShown = true;
11082                  var w = document.getElementById("lc-warn");
11083                  if (w) w.classList.remove("hidden");
11084                }
11085                lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
11086                setTimeout(function() { lcPoll(waitId); }, 1500);
11087              }
11088            })
11089            .catch(function() {
11090              pollRetries++;
11091              if (pollRetries >= 5) {
11092                lcShowError("Lost connection to server. Reload to check status.");
11093              } else {
11094                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
11095              }
11096            });
11097        }
11098
11099        var params = new URLSearchParams(formData);
11100        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
11101          .then(function(r) {
11102            var waitId = r.headers.get("x-wait-id");
11103            if (!waitId) { window.location.href = "/scan"; return; }
11104            activeWaitId = waitId;
11105            setTimeout(function() { lcPoll(waitId); }, 1500);
11106          })
11107          .catch(function(err) {
11108            lcShowError("Could not reach server: " + (err.message || err));
11109          });
11110      }
11111
11112      if (quickScanBtn) {
11113        quickScanBtn.addEventListener("click", function () {
11114          var pathVal = pathInput ? pathInput.value.trim() : "";
11115          if (!pathVal) {
11116            alert("Please enter or browse to a project path first.");
11117            return;
11118          }
11119          quickScanBtn.disabled = true;
11120          quickScanBtn.textContent = "Scanning...";
11121          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
11122          startAsyncAnalysis(new FormData(form));
11123        });
11124      }
11125
11126      var mixedPolicyInfo = {
11127        code_only: {
11128          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.",
11129          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'
11130        },
11131        code_and_comment: {
11132          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.",
11133          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'
11134        },
11135        comment_only: {
11136          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.",
11137          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'
11138        },
11139        separate_mixed_category: {
11140          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.",
11141          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'
11142        }
11143      };
11144
11145      var scanPresetInfo = {
11146        balanced: {
11147          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.",
11148          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
11149          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
11150          note: "Best when you want a stable local overview before making deeper adjustments.",
11151          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
11152        },
11153        code_focused: {
11154          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
11155          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
11156          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
11157          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
11158          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
11159        },
11160        comment_audit: {
11161          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
11162          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
11163          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
11164          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
11165          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
11166        },
11167        deep_review: {
11168          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
11169          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
11170          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
11171          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
11172          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
11173        }
11174      };
11175
11176      var artifactPresetInfo = {
11177        review: {
11178          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.",
11179          chips: ["HTML", "PDF"],
11180          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
11181        },
11182        full: {
11183          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.",
11184          chips: ["HTML", "PDF", "JSON"],
11185          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
11186        },
11187        html_only: {
11188          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.",
11189          chips: ["HTML only", "Fast local review"],
11190          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
11191        },
11192        machine: {
11193          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
11194          chips: ["HTML", "JSON"],
11195          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
11196        }
11197      };
11198
11199      function applyTheme(theme) {
11200        if (theme === "dark") document.body.classList.add("dark-theme");
11201        else document.body.classList.remove("dark-theme");
11202      }
11203
11204      function loadSavedTheme() {
11205        var saved = null;
11206        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
11207        applyTheme(saved === "dark" ? "dark" : "light");
11208      }
11209
11210      function updateScrollProgress() {
11211        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
11212        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
11213        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
11214        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
11215        var step = Math.min(Math.max(currentStep, 1), 4);
11216        var base = stepBase[step];
11217        var end  = stepEnd[step];
11218
11219        var scrollFrac = 0;
11220        var activePanel = document.querySelector(".wizard-step.active");
11221        if (activePanel) {
11222          var scrollTop = window.scrollY || window.pageYOffset || 0;
11223          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
11224          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
11225          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
11226          var scrolled = scrollTop + viewH - panelTop;
11227          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
11228        }
11229
11230        var percent = Math.round(base + (end - base) * scrollFrac);
11231        percent = Math.min(end, Math.max(base, percent));
11232        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
11233        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
11234      }
11235
11236      function updateWizardProgress() {
11237        updateScrollProgress();
11238      }
11239
11240      var stepDescriptions = [
11241        "Choose a project folder, apply scope filters, and preview which files will be counted.",
11242        "Configure how mixed code-plus-comment lines and docstrings are classified.",
11243        "Pick your output formats, scan preset, and where reports are saved.",
11244        "Review all settings and launch the analysis."
11245      ];
11246
11247      function updateStepNav(step) {
11248        var infoLabel = document.getElementById("step-nav-info-label");
11249        var infoDesc  = document.getElementById("step-nav-info-desc");
11250        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
11251        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
11252      }
11253
11254      function updateSidebarSummary() {
11255        var sumPath    = document.getElementById("sum-path");
11256        var sumPreset  = document.getElementById("sum-preset");
11257        var sumOutput  = document.getElementById("sum-output");
11258        var sidebarSummary = document.getElementById("sidebar-summary");
11259        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
11260        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
11261        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
11262        if (sumPath)   sumPath.textContent   = pathVal   || "—";
11263        if (sumPreset) sumPreset.textContent = presetVal || "—";
11264        if (sumOutput) sumOutput.textContent = outputVal || "—";
11265        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
11266      }
11267
11268      function setStep(step, pushHistory) {
11269        currentStep = step;
11270        stepPanels.forEach(function (panel) {
11271          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
11272        });
11273        stepButtons.forEach(function (button) {
11274          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
11275        });
11276        var layoutEl = document.querySelector(".layout");
11277        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
11278        updateWizardProgress();
11279        updateStepNav(step);
11280        stepButtons.forEach(function(btn) {
11281          var t = Number(btn.getAttribute("data-step-target"));
11282          btn.classList.toggle("done", t < step);
11283        });
11284        updateSidebarSummary();
11285
11286        if (pushHistory !== false) {
11287          try {
11288            history.pushState({ wizardStep: step }, "", "#step" + step);
11289          } catch (e) {}
11290        }
11291
11292        window.scrollTo({ top: 0, behavior: "instant" });
11293      }
11294
11295      window.addEventListener("popstate", function (e) {
11296        if (e.state && e.state.wizardStep) {
11297          setStep(e.state.wizardStep, false);
11298        } else {
11299          var hashMatch = location.hash.match(/^#step([1-4])$/);
11300          if (hashMatch) setStep(Number(hashMatch[1]), false);
11301        }
11302      });
11303
11304      function inferTitleFromPath(value) {
11305        if (!value) return "project";
11306        var cleaned = value.replace(/[\/\\]+$/, "");
11307        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
11308        return parts.length ? parts[parts.length - 1] : value;
11309      }
11310
11311      function updateReportTitleFromPath() {
11312        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
11313        if (!reportTitleTouched) {
11314          reportTitleInput.value = inferred;
11315        }
11316        var title = reportTitleInput.value || inferred;
11317        if (liveReportTitle) liveReportTitle.textContent = title;
11318        if (reportTitlePreview) reportTitlePreview.textContent = title;
11319        document.title = "OxideSLOC | " + title;
11320
11321        var projectPath = (pathInput.value || "").trim();
11322        if (navProjectPill && navProjectTitle) {
11323          if (projectPath.length > 0) {
11324            navProjectTitle.textContent = inferred;
11325            navProjectPill.classList.add("visible");
11326          } else {
11327            navProjectTitle.textContent = "";
11328            navProjectPill.classList.remove("visible");
11329          }
11330        }
11331      }
11332
11333      function updateMixedPolicyUI() {
11334        var key = mixedLinePolicy.value || "code_only";
11335        var info = mixedPolicyInfo[key];
11336        document.getElementById("mixed-policy-description").textContent = info.description;
11337        document.getElementById("mixed-policy-example").textContent = info.example;
11338      }
11339
11340      function updatePythonDocstringUI() {
11341        var checked = !!pythonDocstrings.checked;
11342        document.getElementById("python-docstring-example").textContent = checked
11343          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
11344          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
11345        document.getElementById("python-docstring-live-help").textContent = checked
11346          ? "Enabled: docstrings contribute to comment-style totals."
11347          : "Disabled: docstrings are not counted as comment content.";
11348      }
11349
11350      function renderPresetChips(targetId, chips) {
11351        var target = document.getElementById(targetId);
11352        if (!target) return;
11353        target.innerHTML = (chips || []).map(function (chip) {
11354          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
11355        }).join('');
11356      }
11357
11358      function updatePresetDescriptions() {
11359        var scanInfo = scanPresetInfo[scanPreset.value];
11360        var artifactInfo = artifactPresetInfo[artifactPreset.value];
11361        document.getElementById("scan-preset-description").textContent = scanInfo.description;
11362        document.getElementById("scan-preset-example").textContent = scanInfo.example;
11363        document.getElementById("scan-preset-note").textContent = scanInfo.note;
11364        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
11365        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
11366        renderPresetChips("scan-preset-summary", scanInfo.chips);
11367        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
11368      }
11369
11370      function applyScanPreset() {
11371        var info = scanPresetInfo[scanPreset.value];
11372        if (!info || !info.apply) return;
11373        mixedLinePolicy.value = info.apply.mixed;
11374        pythonDocstrings.checked = !!info.apply.docstrings;
11375        document.getElementById("generated_file_detection").value = info.apply.generated;
11376        document.getElementById("minified_file_detection").value = info.apply.minified;
11377        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
11378        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
11379        document.getElementById("binary_file_behavior").value = info.apply.binary;
11380        updateMixedPolicyUI();
11381        updatePythonDocstringUI();
11382      }
11383
11384      function applyArtifactPreset() {
11385        var enabled = { html: false, pdf: false };
11386        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
11387        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
11388        if (artifactPreset.value === "html_only") { enabled.html = true; }
11389        if (artifactPreset.value === "machine") { enabled.html = true; }
11390
11391        artifactCards.forEach(function (card) {
11392          var artifact = card.getAttribute("data-artifact");
11393          if (artifact === "json") return;
11394          var checked = !!enabled[artifact];
11395          var checkbox = card.querySelector(".artifact-checkbox");
11396          checkbox.checked = checked;
11397          card.classList.toggle("selected", checked);
11398        });
11399      }
11400
11401      function toggleArtifactCard(card) {
11402        var checkbox = card.querySelector(".artifact-checkbox");
11403        checkbox.checked = !checkbox.checked;
11404        card.classList.toggle("selected", checkbox.checked);
11405      }
11406
11407      function updateReview() {
11408        var scanSummary = document.getElementById("review-scan-summary");
11409        var countSummary = document.getElementById("review-count-summary");
11410        var artifactSummary = document.getElementById("review-artifact-summary");
11411        var outputSummary = document.getElementById("review-output-summary");
11412        var previewSummary = document.getElementById("review-preview-summary");
11413        var readinessSummary = document.getElementById("review-readiness-summary");
11414        var includeText = document.getElementById("include_globs").value.trim();
11415        var excludeText = document.getElementById("exclude_globs").value.trim();
11416        var sidePathPreview = document.getElementById("side-path-preview");
11417        var sideOutputPreview = document.getElementById("side-output-preview");
11418        var sideTitlePreview = document.getElementById("side-title-preview");
11419
11420        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
11421        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
11422        if (sideTitlePreview) {
11423          var rt = document.getElementById("report_title");
11424          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
11425        }
11426
11427        scanSummary.innerHTML = ""
11428          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
11429          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
11430          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
11431
11432        countSummary.innerHTML = ""
11433          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
11434          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
11435          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
11436          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
11437          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
11438          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
11439          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
11440          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
11441
11442        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
11443        artifactSummary.innerHTML = ""
11444          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
11445          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
11446
11447        outputSummary.innerHTML = ""
11448          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
11449          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
11450
11451        if (previewSummary) {
11452          if (GIT_MODE) {
11453            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>';
11454          } else {
11455          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
11456          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
11457          var statMap = {};
11458          statButtons.forEach(function (button) {
11459            var valueNode = button.querySelector('.scope-stat-value');
11460            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
11461          });
11462          previewSummary.innerHTML = ''
11463            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
11464            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
11465            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
11466            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
11467            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
11468            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
11469
11470          if (readinessSummary) {
11471            var selectedArtifactsCount = selectedArtifacts.length;
11472            readinessSummary.innerHTML = ''
11473              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
11474              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
11475              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
11476              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
11477          }
11478          } // end else (non-GIT_MODE)
11479        }
11480      }
11481
11482      function escapeHtml(value) {
11483        return String(value)
11484          .replace(/&/g, "&amp;")
11485          .replace(/</g, "&lt;")
11486          .replace(/>/g, "&gt;")
11487          .replace(/"/g, "&quot;")
11488          .replace(/'/g, "&#39;");
11489      }
11490
11491      function isPythonVisible() {
11492        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
11493      }
11494
11495      function syncPythonVisibility() {
11496        var html = previewPanel.textContent || "";
11497        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
11498        pythonWraps.forEach(function (node) {
11499          node.classList.toggle("hidden", !hasPython);
11500        });
11501      }
11502
11503      function attachPreviewInteractions() {
11504        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
11505        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
11506        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
11507        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
11508        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
11509        var searchInput = previewPanel.querySelector("#explorer-search");
11510        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
11511        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
11512        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
11513        var activeFilter = "all";
11514        var activeLanguage = "";
11515        var searchTerm = "";
11516        var currentSortKey = null;
11517        var currentSortOrder = "asc";
11518        var childRows = {};
11519
11520        rows.forEach(function (row) {
11521          var parentId = row.getAttribute("data-parent-id") || "";
11522          var rowId = row.getAttribute("data-row-id") || "";
11523          if (!childRows[parentId]) childRows[parentId] = [];
11524          childRows[parentId].push(rowId);
11525        });
11526
11527        function rowById(id) {
11528          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
11529        }
11530
11531        function hasCollapsedAncestor(row) {
11532          var parentId = row.getAttribute("data-parent-id");
11533          while (parentId) {
11534            var parent = rowById(parentId);
11535            if (!parent) break;
11536            if (parent.getAttribute("data-expanded") === "false") return true;
11537            parentId = parent.getAttribute("data-parent-id");
11538          }
11539          return false;
11540        }
11541
11542        function updateToggleGlyph(row) {
11543          var toggle = row.querySelector(".tree-toggle");
11544          if (!toggle) return;
11545          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
11546        }
11547
11548        function rowSortValue(row, key) {
11549          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
11550        }
11551
11552        function updateSortButtons() {
11553          sortButtons.forEach(function (button) {
11554            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
11555            var indicator = button.querySelector(".tree-sort-indicator");
11556            button.classList.toggle("active", isActive);
11557            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
11558            if (indicator) {
11559              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
11560            }
11561          });
11562        }
11563
11564        function sortSiblingRows() {
11565          if (!treeContainer) {
11566            updateSortButtons();
11567            return;
11568          }
11569
11570          var rowMap = {};
11571          var childrenMap = {};
11572          rows.forEach(function (row) {
11573            var rowId = row.getAttribute("data-row-id");
11574            var parentId = row.getAttribute("data-parent-id") || "";
11575            rowMap[rowId] = row;
11576            if (!childrenMap[parentId]) childrenMap[parentId] = [];
11577            childrenMap[parentId].push(rowId);
11578          });
11579
11580          Object.keys(childrenMap).forEach(function (parentId) {
11581            if (!parentId) return;
11582            childrenMap[parentId].sort(function (a, b) {
11583              var rowA = rowMap[a];
11584              var rowB = rowMap[b];
11585              if (!currentSortKey) {
11586                return Number(a) - Number(b);
11587              }
11588              var valueA = rowSortValue(rowA, currentSortKey);
11589              var valueB = rowSortValue(rowB, currentSortKey);
11590              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
11591              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
11592              var fallbackA = rowSortValue(rowA, "name");
11593              var fallbackB = rowSortValue(rowB, "name");
11594              if (fallbackA < fallbackB) return -1;
11595              if (fallbackA > fallbackB) return 1;
11596              return Number(a) - Number(b);
11597            });
11598          });
11599
11600          var orderedIds = [];
11601          function pushChildren(parentId) {
11602            (childrenMap[parentId] || []).forEach(function (childId) {
11603              orderedIds.push(childId);
11604              pushChildren(childId);
11605            });
11606          }
11607
11608          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
11609            orderedIds.push(topId);
11610            pushChildren(topId);
11611          });
11612
11613          orderedIds.forEach(function (id) {
11614            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
11615          });
11616          updateSortButtons();
11617        }
11618
11619        function updateLanguageButtons() {
11620          languageButtons.forEach(function (button) {
11621            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
11622            var isActive = languageValue === activeLanguage;
11623            button.classList.toggle("active", isActive);
11624          });
11625        }
11626
11627        function rowSelfMatches(row) {
11628          var kind = row.getAttribute("data-kind");
11629          var status = row.getAttribute("data-status");
11630          var language = (row.getAttribute("data-language") || "").toLowerCase();
11631          var name = row.getAttribute("data-name-lower") || "";
11632          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
11633          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
11634          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
11635          var passesLanguage = !activeLanguage || language === activeLanguage;
11636          return passesFilter && passesSearch && passesLanguage;
11637        }
11638
11639        function hasMatchingDescendant(rowId) {
11640          return (childRows[rowId] || []).some(function (childId) {
11641            var childRow = rowById(childId);
11642            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
11643          });
11644        }
11645
11646        function rowMatches(row) {
11647          if (rowSelfMatches(row)) return true;
11648          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
11649        }
11650
11651        function resetViewState() {
11652          activeFilter = "all";
11653          activeLanguage = "";
11654          searchTerm = "";
11655          currentSortKey = null;
11656          currentSortOrder = "asc";
11657          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11658          if (searchInput) searchInput.value = "";
11659          if (filterSelect) filterSelect.value = "all";
11660          updateLanguageButtons();
11661        }
11662
11663        function applyVisibility() {
11664          rows.forEach(function (row) {
11665            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
11666            row.classList.toggle("hidden-by-filter", !visible);
11667            row.style.display = visible ? "grid" : "none";
11668          });
11669          buttons.forEach(function (button) {
11670            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
11671          });
11672          if (filterSelect) filterSelect.value = activeFilter;
11673        }
11674
11675        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
11676        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
11677        var originalStats = {};
11678        buttons.forEach(function (btn) {
11679          var f = btn.getAttribute('data-filter');
11680          var v = btn.querySelector('.scope-stat-value');
11681          if (f && v) originalStats[f] = v.textContent;
11682        });
11683
11684        function applySubmoduleStats(statsJson) {
11685          try {
11686            var s = JSON.parse(statsJson);
11687            buttons.forEach(function (btn) {
11688              var f = btn.getAttribute('data-filter');
11689              var v = btn.querySelector('.scope-stat-value');
11690              if (!v) return;
11691              if (f === 'dir') v.textContent = s.dirs;
11692              else if (f === 'file') v.textContent = s.files;
11693              else if (f === 'supported') v.textContent = s.supported;
11694              else if (f === 'skipped') v.textContent = s.skipped;
11695              else if (f === 'unsupported') v.textContent = s.unsupported;
11696            });
11697          } catch (e) {}
11698        }
11699
11700        function restoreBaseRepoStats() {
11701          buttons.forEach(function (btn) {
11702            var f = btn.getAttribute('data-filter');
11703            var v = btn.querySelector('.scope-stat-value');
11704            if (v && originalStats[f]) v.textContent = originalStats[f];
11705          });
11706          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11707          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
11708        }
11709
11710        submoduleChips.forEach(function (chip) {
11711          chip.addEventListener('click', function () {
11712            var statsJson = chip.getAttribute('data-sub-stats');
11713            if (!statsJson) return;
11714            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11715            chip.classList.add('active');
11716            applySubmoduleStats(statsJson);
11717            if (baseRepoBtn) baseRepoBtn.style.display = '';
11718          });
11719        });
11720
11721        if (baseRepoBtn) {
11722          baseRepoBtn.addEventListener('click', function () {
11723            restoreBaseRepoStats();
11724            resetViewState();
11725            sortSiblingRows();
11726            applyVisibility();
11727          });
11728        }
11729
11730        buttons.forEach(function (button) {
11731          button.addEventListener("click", function () {
11732            var filterValue = button.getAttribute("data-filter") || "all";
11733            if (filterValue === "reset-view") {
11734              restoreBaseRepoStats();
11735              resetViewState();
11736              sortSiblingRows();
11737              applyVisibility();
11738              return;
11739            }
11740            activeFilter = filterValue;
11741            applyVisibility();
11742          });
11743        });
11744
11745        rows.forEach(function (row) {
11746          updateToggleGlyph(row);
11747          var toggle = row.querySelector(".tree-toggle");
11748          if (toggle) {
11749            toggle.addEventListener("click", function () {
11750              var expanded = row.getAttribute("data-expanded") !== "false";
11751              row.setAttribute("data-expanded", expanded ? "false" : "true");
11752              updateToggleGlyph(row);
11753              applyVisibility();
11754            });
11755          }
11756        });
11757
11758        actionButtons.forEach(function (button) {
11759          button.addEventListener("click", function () {
11760            var action = button.getAttribute("data-explorer-action");
11761            if (action === "expand-all") {
11762              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11763            } else if (action === "collapse-all") {
11764              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
11765            } else if (action === "clear-filters") {
11766              resetViewState();
11767            }
11768            sortSiblingRows();
11769            applyVisibility();
11770          });
11771        });
11772
11773        if (filterSelect) {
11774          filterSelect.addEventListener("change", function () {
11775            activeFilter = filterSelect.value || "all";
11776            applyVisibility();
11777          });
11778        }
11779
11780        languageButtons.forEach(function (button) {
11781          button.addEventListener("click", function () {
11782            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
11783            updateLanguageButtons();
11784            applyVisibility();
11785          });
11786        });
11787
11788        sortButtons.forEach(function (button) {
11789          button.addEventListener("click", function () {
11790            var sortKey = button.getAttribute("data-sort-key");
11791            if (currentSortKey === sortKey) {
11792              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
11793            } else {
11794              currentSortKey = sortKey;
11795              currentSortOrder = "asc";
11796            }
11797            sortSiblingRows();
11798            applyVisibility();
11799          });
11800        });
11801
11802        if (searchInput) {
11803          searchInput.addEventListener("input", function () {
11804            searchTerm = searchInput.value.trim().toLowerCase();
11805            applyVisibility();
11806          });
11807        }
11808
11809        updateLanguageButtons();
11810        sortSiblingRows();
11811        applyVisibility();
11812      }
11813
11814      function loadPreview() {
11815        if (!previewPanel || !pathInput) return;
11816        if (GIT_MODE) {
11817          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>';
11818          return;
11819        }
11820        var path = pathInput.value.trim();
11821        var zeroWarn = document.getElementById('zero-files-warning');
11822        if (!path) {
11823          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
11824          if (zeroWarn) zeroWarn.style.display = 'none';
11825          return;
11826        }
11827        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
11828        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
11829        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
11830        var previewUrl = "/preview?path=" + encodeURIComponent(path)
11831          + "&include_globs=" + encodeURIComponent(includeValue)
11832          + "&exclude_globs=" + encodeURIComponent(excludeValue);
11833        fetch(previewUrl)
11834          .then(function (response) { return response.text(); })
11835          .then(function (html) {
11836            previewPanel.innerHTML = html;
11837            attachPreviewInteractions();
11838            syncPythonVisibility();
11839            updateReview();
11840            setTimeout(collapseLanguagePills, 50);
11841            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
11842            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
11843            var sizeText = document.getElementById('project-size-text');
11844            var sizeBtn = document.getElementById('project-size-btn');
11845            if (sizeText && projectSize) {
11846              sizeText.textContent = 'Project size: ' + projectSize;
11847              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
11848            } else if (sizeText) {
11849              sizeText.textContent = 'Project size: —';
11850            }
11851            if (zeroWarn) {
11852              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
11853              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
11854              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
11855              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
11856              if (supportedCount === 0 && fileCount > 0) {
11857                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).';
11858                zeroWarn.style.display = '';
11859              } else {
11860                zeroWarn.style.display = 'none';
11861              }
11862            }
11863          })
11864          .catch(function (err) {
11865            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
11866          });
11867      }
11868
11869      function pickDirectory(targetInput, kind) {
11870        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
11871        if (browseButton) browseButton.disabled = true;
11872
11873        if (previewPanel && targetInput === pathInput) {
11874          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
11875        }
11876
11877        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
11878          .then(function (response) { return response.json(); })
11879          .then(function (data) {
11880            if (data && data.selected_path) {
11881              targetInput.value = data.selected_path;
11882
11883              if (targetInput === pathInput) {
11884                updateReportTitleFromPath();
11885                autoSetOutputDir(data.selected_path);
11886                fetchProjectHistory(data.selected_path);
11887                loadPreview();
11888                suggestCoverageFile(data.selected_path);
11889              }
11890
11891              updateReview();
11892            } else if (targetInput === pathInput) {
11893              // Cancelled — keep existing value and refresh preview with current path
11894              loadPreview();
11895            }
11896          })
11897          .catch(function () {
11898            window.alert("Directory picker request failed.");
11899            if (previewPanel && targetInput === pathInput) {
11900              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
11901            }
11902          })
11903          .finally(function () {
11904            if (browseButton) browseButton.disabled = false;
11905          });
11906      }
11907
11908      if (themeToggle) {
11909        themeToggle.addEventListener("click", function () {
11910          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
11911          applyTheme(nextTheme);
11912          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
11913        });
11914      }
11915
11916      stepButtons.forEach(function (button) {
11917        button.addEventListener("click", function () {
11918          setStep(Number(button.getAttribute("data-step-target")));
11919        });
11920      });
11921
11922      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
11923        button.addEventListener("click", function () {
11924          setStep(Number(button.getAttribute("data-step-target")) || 1);
11925        });
11926      });
11927
11928      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
11929        button.addEventListener("click", function () {
11930          updateReview();
11931          setStep(Number(button.getAttribute("data-next")));
11932        });
11933      });
11934
11935      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
11936        button.addEventListener("click", function () {
11937          setStep(Number(button.getAttribute("data-prev")));
11938        });
11939      });
11940
11941      document.addEventListener("keydown", function (e) {
11942        var tag = (document.activeElement || {}).tagName || "";
11943        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
11944        if (e.altKey || e.ctrlKey || e.metaKey) return;
11945        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
11946        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
11947      });
11948
11949      if (useSamplePath) {
11950        useSamplePath.addEventListener("click", function () {
11951          pathInput.value = "tests/fixtures/basic";
11952          updateReportTitleFromPath();
11953          autoSetOutputDir("tests/fixtures/basic");
11954          loadPreview();
11955          suggestCoverageFile("tests/fixtures/basic");
11956        });
11957      }
11958
11959      if (useDefaultOutput) {
11960        useDefaultOutput.addEventListener("click", function () {
11961          delete outputDirInput.dataset.userEdited;
11962          autoSetOutputDir(pathInput ? pathInput.value : "");
11963          updateReview();
11964        });
11965      }
11966
11967      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
11968      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
11969      if (browseCoverage) {
11970        browseCoverage.addEventListener("click", function () {
11971          browseCoverage.disabled = true;
11972          var currentVal = coverageInput ? coverageInput.value : "";
11973          fetch("/pick-directory?kind=coverage&current=" + encodeURIComponent(currentVal))
11974            .then(function (r) { return r.json(); })
11975            .then(function (d) {
11976              if (d && d.selected_path && coverageInput) {
11977                coverageInput.value = d.selected_path;
11978                setCovStatus("idle");
11979              }
11980            })
11981            .catch(function () {})
11982            .finally(function () { browseCoverage.disabled = false; });
11983        });
11984      }
11985
11986      function setCovStatus(state, opts) {
11987        if (!covScanStatus) return;
11988        opts = opts || {};
11989        covScanStatus.className = "cov-scan-status cov-scan-" + state;
11990        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
11991        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>';
11992        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>';
11993        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>';
11994        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>';
11995        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
11996        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
11997        if (state === "scanning") {
11998          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
11999        } else if (state === "found") {
12000          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
12001          html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
12002          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
12003          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
12004        } else if (state === "hint") {
12005          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
12006          html += '<div class="cov-scan-title">' + tb2 + ' detected &mdash; no coverage file found yet</div>';
12007          html += '<div class="cov-scan-sub">Generate one with:</div>';
12008          html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
12009        } else if (state === "none") {
12010          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
12011          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
12012        }
12013        html += '</div></div>';
12014        covScanStatus.innerHTML = html;
12015        if (state === "found") {
12016          var useBtn = covScanStatus.querySelector(".cov-scan-use");
12017          if (useBtn) useBtn.addEventListener("click", function () {
12018            if (coverageInput) coverageInput.value = "";
12019            covAutoFilled = false;
12020            setCovStatus("idle");
12021          });
12022        }
12023      }
12024
12025      function suggestCoverageFile(projectPath) {
12026        if (!coverageInput || !covScanStatus) return;
12027        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
12028        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
12029        clearTimeout(coverageSuggestTimer);
12030        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
12031        setCovStatus("scanning");
12032        coverageSuggestTimer = setTimeout(function () {
12033          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
12034            .then(function (r) { return r.json(); })
12035            .then(function (d) {
12036              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
12037              if (!d) { setCovStatus("none"); return; }
12038              if (d.found) {
12039                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
12040                setCovStatus("found", { found: d.found, tool: d.tool });
12041              } else if (d.tool && d.hint) {
12042                setCovStatus("hint", { tool: d.tool, hint: d.hint });
12043              } else {
12044                setCovStatus("none");
12045              }
12046            })
12047            .catch(function () { setCovStatus("idle"); });
12048        }, 600);
12049      }
12050
12051      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
12052
12053      if (coverageInput) coverageInput.addEventListener("input", function () {
12054        covAutoFilled = false;
12055        if (!this.value.trim()) setCovStatus("idle");
12056      });
12057
12058      // ── Language pill overflow: collapse to "+N more" chip ─────────────
12059      function collapseLanguagePills() {
12060        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
12061        rows.forEach(function(row) {
12062          // Remove any previous overflow chip
12063          var prev = row.querySelector('.lang-overflow-chip');
12064          if (prev) prev.remove();
12065          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
12066          pills.forEach(function(p) { p.style.display = ''; });
12067          if (!pills.length) return;
12068
12069          // Measure after restoring all pills
12070          var containerRight = row.getBoundingClientRect().right;
12071          var hidden = [];
12072          for (var i = pills.length - 1; i >= 1; i--) {
12073            var rect = pills[i].getBoundingClientRect();
12074            if (rect.right > containerRight + 2) {
12075              hidden.unshift(pills[i]);
12076              pills[i].style.display = 'none';
12077            } else {
12078              break;
12079            }
12080          }
12081
12082          if (hidden.length) {
12083            var chip = document.createElement('button');
12084            chip.type = 'button';
12085            chip.className = 'language-pill lang-overflow-chip';
12086            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
12087            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
12088            row.appendChild(chip);
12089          }
12090        });
12091      }
12092
12093      // Run after preview loads (preview panel populates language pills)
12094      var _origLoadPreviewCb = window.__previewLoaded;
12095      document.addEventListener('previewLoaded', collapseLanguagePills);
12096      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
12097      setTimeout(collapseLanguagePills, 400);
12098
12099      // ── Project history & output dir auto-set ──────────────────────────
12100      var wsOutputRoot   = document.getElementById("ws-output-root");
12101      var wsScanCount    = document.getElementById("ws-scan-count");
12102      var wsLastScan     = document.getElementById("ws-last-scan");
12103      var historyBadge   = document.getElementById("path-history-badge");
12104      var historyTimer   = null;
12105
12106      var wsOutputLink = document.getElementById("ws-output-link");
12107      function syncStripOutputRoot() {
12108        var val = outputDirInput ? outputDirInput.value : "";
12109        var display = val || "project/sloc";
12110        if (wsOutputRoot) wsOutputRoot.textContent = display;
12111        if (wsOutputLink) wsOutputLink.dataset.folder = val;
12112      }
12113
12114      function autoSetOutputDir(projectPath) {
12115        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
12116        if (GIT_MODE && GIT_OUTPUT_DIR) {
12117          outputDirInput.value = GIT_OUTPUT_DIR;
12118          syncStripOutputRoot();
12119          updateReview();
12120          return;
12121        }
12122        if (!projectPath || !projectPath.trim()) return;
12123        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
12124        outputDirInput.value = cleaned + "/sloc";
12125        syncStripOutputRoot();
12126        updateReview();
12127      }
12128
12129      var wsBranch = document.getElementById("ws-branch");
12130
12131      function fetchProjectHistory(projectPath) {
12132        if (!projectPath || !projectPath.trim()) {
12133          if (wsScanCount) wsScanCount.textContent = "—";
12134          if (wsLastScan)  wsLastScan.textContent  = "—";
12135          if (wsBranch)    wsBranch.textContent    = "—";
12136          if (historyBadge) historyBadge.style.display = "none";
12137          return;
12138        }
12139        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
12140          .then(function (r) { return r.ok ? r.json() : null; })
12141          .then(function (data) {
12142            if (!data) return;
12143            var countStr = data.scan_count > 0
12144              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
12145              : "never";
12146            var tsStr = data.last_scan_timestamp
12147              ? data.last_scan_timestamp.replace(" UTC","")
12148              : "—";
12149            if (wsScanCount) wsScanCount.textContent = countStr;
12150            if (wsLastScan)  wsLastScan.textContent  = tsStr;
12151            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
12152            if (data.scan_count > 0) {
12153              if (historyBadge) {
12154                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
12155                historyBadge.textContent = data.scan_count + " previous scan" +
12156                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
12157                  "Last: " + (data.last_scan_timestamp || "—") +
12158                  " — " + (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.";
12159                historyBadge.className = "path-history-badge found";
12160                historyBadge.style.display = "";
12161              }
12162            } else {
12163              if (historyBadge) historyBadge.style.display = "none";
12164            }
12165          })
12166          .catch(function () {});
12167      }
12168
12169      function onPathChange() {
12170        var val = pathInput ? pathInput.value : "";
12171        updateReportTitleFromPath();
12172        autoSetOutputDir(val);
12173        updateSidebarSummary();
12174        clearTimeout(historyTimer);
12175        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
12176        if (previewTimer) clearTimeout(previewTimer);
12177        previewTimer = setTimeout(loadPreview, 280);
12178        suggestCoverageFile(val);
12179      }
12180
12181      if (pathInput) {
12182        pathInput.addEventListener("input", onPathChange);
12183      }
12184
12185      if (outputDirInput) {
12186        outputDirInput.addEventListener("input", function () {
12187          outputDirInput.dataset.userEdited = "1";
12188          syncStripOutputRoot();
12189          updateReview();
12190        });
12191      }
12192
12193      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
12194        if (!node) return;
12195        node.addEventListener("input", function () {
12196          updateReview();
12197          if (previewTimer) clearTimeout(previewTimer);
12198          previewTimer = setTimeout(loadPreview, 280);
12199        });
12200      });
12201
12202      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
12203        var node = document.getElementById(id);
12204        if (node) node.addEventListener("change", updateReview);
12205      });
12206
12207      if (reportTitleInput) {
12208        reportTitleInput.addEventListener("input", function () {
12209          reportTitleTouched = reportTitleInput.value.trim().length > 0;
12210          updateReportTitleFromPath();
12211          updateReview();
12212        });
12213      }
12214
12215      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
12216      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
12217      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
12218      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
12219
12220      artifactCards.forEach(function (card) {
12221        card.addEventListener("click", function () {
12222          if (card.classList.contains("artifact-locked")) return;
12223          toggleArtifactCard(card);
12224          updateReview();
12225        });
12226      });
12227
12228      if (coverageInput) {
12229        coverageInput.addEventListener("input", function () {
12230          if (coverageInput.value.trim()) setCovStatus("idle");
12231        });
12232      }
12233
12234      if (form && loading && submitButton) {
12235        form.addEventListener("submit", function (e) {
12236          e.preventDefault();
12237          submitButton.disabled = true;
12238          submitButton.textContent = "Scanning...";
12239          startAsyncAnalysis(new FormData(form));
12240        });
12241      }
12242
12243      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
12244        btn.addEventListener('click', function () {
12245          var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
12246          if (!folder) return;
12247          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12248        });
12249      });
12250
12251      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
12252      if (wsOutputLink) {
12253        wsOutputLink.addEventListener('click', function () {
12254          var folder = wsOutputLink.dataset.folder || '';
12255          if (!folder) return;
12256          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12257        });
12258      }
12259
12260      loadSavedTheme();
12261      updateMixedPolicyUI();
12262      updatePythonDocstringUI();
12263      applyScanPreset();
12264      updatePresetDescriptions();
12265      applyArtifactPreset();
12266      updateReview();
12267      updateScrollProgress(); // initialise bar to 0% (step 1)
12268      window.addEventListener("scroll", updateScrollProgress, { passive: true });
12269      onPathChange();         // seed output dir, history badge, and preview from initial path
12270      loadPreview();
12271      updateStepNav(1);
12272
12273      // Restore step from URL hash on initial load (e.g., back-forward cache)
12274      (function() {
12275        var hashMatch = location.hash.match(/^#step([1-4])$/);
12276        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
12277      })();
12278
12279      (function randomizeWatermarks() {
12280        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
12281        if (!wms.length) return;
12282        var placed = [];
12283        function tooClose(top, left) {
12284          for (var i = 0; i < placed.length; i++) {
12285            var dt = Math.abs(placed[i][0] - top);
12286            var dl = Math.abs(placed[i][1] - left);
12287            if (dt < 16 && dl < 12) return true;
12288          }
12289          return false;
12290        }
12291        function pick(leftBand) {
12292          for (var attempt = 0; attempt < 50; attempt++) {
12293            var top = Math.random() * 88 + 2;
12294            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12295            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12296          }
12297          var top = Math.random() * 88 + 2;
12298          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12299          placed.push([top, left]);
12300          return [top, left];
12301        }
12302        var half = Math.floor(wms.length / 2);
12303        wms.forEach(function (img, i) {
12304          var pos = pick(i < half);
12305          var size = Math.floor(Math.random() * 80 + 110);
12306          var rot = (Math.random() * 360).toFixed(1);
12307          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
12308          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;
12309        });
12310      })();
12311
12312      (function spawnCodeParticles() {
12313        var container = document.getElementById('code-particles');
12314        if (!container) return;
12315        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'];
12316        for (var i = 0; i < 38; i++) {
12317          (function(idx) {
12318            var el = document.createElement('span');
12319            el.className = 'code-particle';
12320            el.textContent = snippets[idx % snippets.length];
12321            var left = Math.random() * 94 + 2;
12322            var top = Math.random() * 88 + 6;
12323            var dur = (Math.random() * 10 + 9).toFixed(1);
12324            var delay = (Math.random() * 18).toFixed(1);
12325            var rot = (Math.random() * 26 - 13).toFixed(1);
12326            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12327            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';
12328            container.appendChild(el);
12329          })(i);
12330        }
12331      })();
12332    })();
12333  </script>
12334  <script nonce="{{ csp_nonce }}">
12335    (function () {
12336      var raw = {{ prefill_json|safe }};
12337      if (!raw || typeof raw !== 'object' || !raw.path) return;
12338      function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12339      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
12340      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12341      setVal('path-input', raw.path || '');
12342      setVal('include-globs', raw.include_globs || '');
12343      setVal('exclude-globs', raw.exclude_globs || '');
12344      setVal('output-dir', raw.output_dir || '');
12345      setVal('report-title', raw.report_title || '');
12346      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
12347      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
12348      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
12349      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
12350      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
12351      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
12352      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
12353      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
12354      setChecked('generate-html', raw.generate_html !== false);
12355      setChecked('generate-pdf', !!raw.generate_pdf);
12356      // Trigger dynamic UI updates after pre-fill.
12357      setTimeout(function () {
12358        var pathEl = document.getElementById('path-input');
12359        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
12360        var policyEl = document.getElementById('mixed-line-policy');
12361        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
12362      }, 80);
12363    })();
12364  </script>
12365  <script nonce="{{ csp_nonce }}">
12366  (function(){
12367    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'}];
12368    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);});}
12369    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12370    function init(){
12371      var btn=document.getElementById('settings-btn');if(!btn)return;
12372      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12373      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>';
12374      document.body.appendChild(m);
12375      var g=document.getElementById('scheme-grid');
12376      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);});
12377      var cl=document.getElementById('settings-close');
12378      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);
12379      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');});
12380      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12381      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12382    }
12383    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12384  }());
12385  </script>
12386  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
12387    <div class="wb-ftip-arrow"></div>
12388    <span id="wb-ftip-text"></span>
12389  </div>
12390  <script nonce="{{ csp_nonce }}">(function(){
12391    var tip=document.getElementById('wb-ftip');
12392    var txt=document.getElementById('wb-ftip-text');
12393    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
12394    if(!tip||!txt)return;
12395    function pos(el){
12396      var r=el.getBoundingClientRect();
12397      tip.style.display='block';
12398      var tw=tip.offsetWidth;
12399      var lx=r.left+r.width/2-tw/2;
12400      if(lx<8)lx=8;
12401      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
12402      tip.style.left=lx+'px';
12403      tip.style.top=(r.bottom+8)+'px';
12404      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';}
12405    }
12406    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
12407      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
12408      el.addEventListener('mouseleave',function(){tip.style.display='none';});
12409    });
12410  })();
12411  (function(){
12412    function fixArtifactHintSpacing(){
12413      var grid=document.querySelector('.artifact-grid');
12414      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
12415    }
12416    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
12417  }());
12418  </script>
12419  <footer class="site-footer">
12420    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
12421    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12422    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12423    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12424    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12425  </footer>
12426</body>
12427</html>
12428"##,
12429    ext = "html"
12430)]
12431struct IndexTemplate {
12432    version: &'static str,
12433    prefill_json: String,
12434    csp_nonce: String,
12435    git_repo: String,
12436    git_ref: String,
12437    git_label_json: String,
12438    git_output_dir_json: String,
12439}
12440
12441// ── SplashTemplate ────────────────────────────────────────────────────────────
12442
12443#[derive(Template)]
12444#[template(
12445    source = r##"
12446<!doctype html>
12447<html lang="en">
12448<head>
12449  <meta charset="utf-8">
12450  <meta name="viewport" content="width=device-width, initial-scale=1">
12451  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
12452  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12453  <style nonce="{{ csp_nonce }}">
12454    :root {
12455      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
12456      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12457      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12458      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12459      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
12460    }
12461    body.dark-theme {
12462      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
12463      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
12464    }
12465    *{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);}
12466    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12467    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
12468    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12469    .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;}
12470    @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));}}
12471    .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);}
12472    .top-nav-inner{max-width:1400px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
12473    .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));}
12474    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
12475    .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;}
12476    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
12477    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12478    @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; } }
12479    .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;}
12480    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
12481    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
12482    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
12483    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
12484    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
12485    .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;}
12486    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12487    .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);}
12488    .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;}
12489    .settings-close:hover{color:var(--text);background:var(--surface-2);}
12490    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12491    .settings-modal-body{padding:14px 16px 16px;}
12492    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12493    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12494    .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;}
12495    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12496    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12497    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12498    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12499    .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;}
12500    .tz-select:focus{border-color:var(--oxide);}
12501    .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;}
12502    .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;}
12503    .page{max-width:1400px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
12504    .hero{text-align:center;margin:0 auto 18px;}
12505    .hero-logo-wrap{display:inline-block;cursor:default;}
12506    .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;}
12507    .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;}
12508    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
12509    .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;}
12510    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%);}
12511    .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;
12512      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
12513      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
12514      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;}
12515    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
12516    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
12517    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;}
12518    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:2.5em;opacity:0;}
12519    .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;}
12520    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
12521    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
12522    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
12523    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
12524    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
12525    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
12526    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
12527    .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;}
12528    .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;}
12529    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
12530    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12531    .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);}
12532    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
12533    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
12534    .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);}
12535    .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);}
12536    .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);}
12537    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
12538    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
12539    .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;}
12540    body.dark-theme .action-card-cta{color:var(--oxide);}
12541    .action-card.view .action-card-cta{color:var(--accent-2);}
12542    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
12543    .action-card.compare .action-card-cta{color:#7c3aed;}
12544    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
12545    .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);}
12546    .action-card.git-tools .action-card-cta{color:#15803d;}
12547    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
12548    .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);}
12549    .action-card.trend .action-card-cta{color:#0e7490;}
12550    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
12551    .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);}
12552    .action-card.automation .action-card-cta{color:#b45309;}
12553    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
12554    .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);}
12555    .action-card.test-metrics .action-card-cta{color:#be185d;}
12556    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
12557    .action-card:hover .action-card-cta{gap:12px;}
12558    .action-card.card-split{flex-direction:row;align-items:stretch;}
12559    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
12560    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
12561    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
12562    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
12563    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
12564    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
12565    .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;}
12566    .ac-badge.active{opacity:1;}
12567    .ac-badge.github{border-color:#555;color:#555;}
12568    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
12569    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
12570    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
12571    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
12572    body.dark-theme .ac-right-row{color:var(--muted);}
12573    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
12574    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
12575    .divider{height:1px;background:var(--line);margin:32px 0;}
12576    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
12577    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
12578    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
12579    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
12580      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
12581    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12582    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
12583    body.dark-theme .info-chip-val{color:var(--oxide);}
12584    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
12585    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
12586      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
12587      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
12588    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
12589      border:6px solid transparent;border-top-color:var(--text);}
12590    .info-chip:hover .info-chip-tip{display:block;}
12591    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
12592    .chip-slide.fading{filter:blur(5px);opacity:0;}
12593    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
12594    .site-footer a{color:var(--muted);}
12595    .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;}
12596    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
12597    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
12598    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
12599    .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;}
12600    .lan-badge.local{background:var(--oxide-2);}
12601    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
12602    .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);}
12603    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
12604    .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;}
12605    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
12606    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
12607    .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;}
12608    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
12609    .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;}
12610    .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);}
12611    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
12612    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
12613    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
12614    .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;}
12615    @media (max-height: 1100px) {
12616      .page{padding-top:10px;}
12617      .hero{margin-bottom:10px;}
12618      .hero-logo{width:54px;height:60px;}
12619      .hero-logo-shadow{width:42px;}
12620      .hero-title{font-size:28px;}
12621      .hero-subtitle{font-size:13px;min-height:2em;}
12622      .card-sections{gap:16px;margin-bottom:10px;}
12623      .card-section-grid-2,.card-section-grid-3{gap:10px;}
12624      .action-card{padding:8px 15px 8px;}
12625      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
12626      .action-card-icon svg{width:18px;height:18px;}
12627      .action-card-title{font-size:13px;}
12628      .action-card-desc{font-size:11px;margin-bottom:6px;}
12629      .action-card-cta{font-size:11px;}
12630      .ac-right-row{font-size:11px;}
12631      .divider{margin:14px 0;}
12632      .info-strip{gap:7px;margin-bottom:12px;}
12633      .info-chip{padding:7px 10px;}
12634      .info-chip-val{font-size:13px;}
12635      .info-chip-label{font-size:9px;}
12636      .site-footer{padding:8px 24px;font-size:12px;}
12637    }
12638    @media (max-height: 850px) {
12639      .page{padding-top:6px;}
12640      .hero{margin-bottom:6px;}
12641      .hero-logo{width:42px;height:46px;}
12642      .hero-title{font-size:22px;}
12643      .hero-subtitle{font-size:12px;min-height:1.6em;}
12644      .card-sections{gap:10px;}
12645      .action-card-desc{margin-bottom:4px;}
12646      .divider{margin:8px 0;}
12647      .info-strip{margin-bottom:6px;}
12648      .lan-local-hint{margin-top:10px;}
12649    }
12650  </style>
12651</head>
12652<body>
12653  <div class="background-watermarks" aria-hidden="true">
12654    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12655    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12656    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12657    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12658    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12659    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12660    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12661  </div>
12662  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12663  <div class="top-nav">
12664    <div class="top-nav-inner">
12665      <a class="brand" href="/">
12666        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12667        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
12668      </a>
12669      <div class="nav-right">
12670        <a class="nav-pill" href="/">Home</a>
12671        <div class="nav-dropdown">
12672          <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>
12673          <div class="nav-dropdown-menu">
12674            <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>
12675          </div>
12676        </div>
12677        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12678        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12679        <div class="nav-dropdown">
12680          <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>
12681          <div class="nav-dropdown-menu">
12682            <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>
12683          </div>
12684        </div>
12685        <div class="server-status-wrap">
12686          {% if server_mode %}
12687          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12688          <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>
12689          {% else %}
12690          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12691          <div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
12692          {% endif %}
12693        </div>
12694        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12695          <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>
12696        </button>
12697        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12698          <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>
12699          <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>
12700        </button>
12701      </div>
12702    </div>
12703  </div>
12704
12705  <div class="page">
12706    <div class="hero">
12707      <div class="hero-logo-wrap" id="hero-logo-wrap">
12708        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
12709      </div>
12710      <div class="hero-logo-shadow"></div>
12711      <div class="hero-title-wrap">
12712        <div class="hero-title-aura" aria-hidden="true"></div>
12713        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
12714      </div>
12715      <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>
12716    </div>
12717
12718    <div class="card-sections">
12719
12720      <div>
12721        <div class="card-section-label">Analysis</div>
12722        <div class="card-section-grid-2">
12723          <a class="action-card scan card-split" href="/scan-setup">
12724            <div class="action-card-left">
12725              <div class="action-card-icon">
12726                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
12727              </div>
12728              <div class="action-card-title">Scan Project</div>
12729              <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>
12730              <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>
12731            </div>
12732            <div class="action-card-sep"></div>
12733            <div class="action-card-right">
12734              <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>
12735              <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>
12736              <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>
12737              <div class="ac-right-stat" id="acp-scan-stat"></div>
12738            </div>
12739          </a>
12740          <a class="action-card test-metrics card-split" href="/test-metrics">
12741            <div class="action-card-left">
12742              <div class="action-card-icon">
12743                <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>
12744              </div>
12745              <div class="action-card-title">Test Metrics</div>
12746              <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>
12747              <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>
12748            </div>
12749            <div class="action-card-sep"></div>
12750            <div class="action-card-right">
12751              <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>
12752              <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>
12753              <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>
12754              <div class="ac-right-stat" id="acp-test-stat"></div>
12755            </div>
12756          </a>
12757        </div>
12758      </div>
12759
12760      <div>
12761        <div class="card-section-label">Reports &amp; Insights</div>
12762        <div class="card-section-grid-3">
12763          <a class="action-card view" href="/view-reports">
12764            <div class="action-card-icon">
12765              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
12766            </div>
12767            <div class="action-card-title">View Reports</div>
12768            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
12769            <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>
12770          </a>
12771          <a class="action-card compare" href="/compare-scans">
12772            <div class="action-card-icon">
12773              <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>
12774            </div>
12775            <div class="action-card-title">Compare Scans</div>
12776            <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>
12777            <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>
12778          </a>
12779          <a class="action-card trend" href="/trend-reports">
12780            <div class="action-card-icon">
12781              <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>
12782            </div>
12783            <div class="action-card-title">Trend Report</div>
12784            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
12785            <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>
12786          </a>
12787        </div>
12788      </div>
12789
12790      <div>
12791        <div class="card-section-label">Developer Tools</div>
12792        <div class="card-section-grid-2">
12793          <a class="action-card git-tools card-split" href="/git-browser">
12794            <div class="action-card-left">
12795              <div class="action-card-icon">
12796                <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>
12797              </div>
12798              <div class="action-card-title">Git Browser</div>
12799              <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>
12800              <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>
12801            </div>
12802            <div class="action-card-sep"></div>
12803            <div class="action-card-right">
12804              <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>
12805              <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>
12806              <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>
12807            </div>
12808          </a>
12809          <a class="action-card automation card-split" href="/integrations">
12810            <div class="action-card-left">
12811              <div class="action-card-icon">
12812                <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>
12813              </div>
12814              <div class="action-card-title">Integrations</div>
12815              <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>
12816              <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>
12817            </div>
12818            <div class="action-card-sep"></div>
12819            <div class="action-card-right">
12820              <div class="ac-badges-grid">
12821                <span class="ac-badge github"     id="acp-gh">GitHub</span>
12822                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
12823                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
12824                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
12825              </div>
12826              <div class="ac-right-stat" id="acp-int-stat"></div>
12827            </div>
12828          </a>
12829        </div>
12830      </div>
12831
12832    </div>
12833
12834    {% if server_mode %}
12835    <div class="lan-card server">
12836      <div class="lan-card-header">
12837        <span class="lan-badge">LAN server</span>
12838        Accessible on your network
12839      </div>
12840      {% if let Some(ip) = lan_ip %}
12841      <div class="lan-url-row">
12842        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
12843        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
12844          <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>
12845          Copy URL
12846        </button>
12847      </div>
12848      <p class="lan-hint">Share this address with anyone on the same network. They will be asked to authenticate.</p>
12849      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
12850      {% else %}
12851      <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>
12852      {% endif %}
12853    </div>
12854    {% endif %}
12855
12856    <div class="divider"></div>
12857
12858    <div class="info-strip">
12859      <div class="info-chip">
12860        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
12861        <div class="chip-slide">
12862          <div class="info-chip-val">41</div>
12863          <div class="info-chip-label">Languages</div>
12864        </div>
12865      </div>
12866      <div class="info-chip">
12867        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
12868        <div class="chip-slide">
12869          <div class="info-chip-val">100%</div>
12870          <div class="info-chip-label">Self-contained</div>
12871        </div>
12872      </div>
12873      <div class="info-chip">
12874        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
12875        <div class="chip-slide">
12876          <div class="info-chip-val">HTML+PDF</div>
12877          <div class="info-chip-label">Exportable reports</div>
12878        </div>
12879      </div>
12880      <div class="info-chip">
12881        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
12882        <div class="chip-slide">
12883          <div class="info-chip-val">Webhook</div>
12884          <div class="info-chip-label">3 platforms</div>
12885        </div>
12886      </div>
12887      <div class="info-chip">
12888        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
12889        <div class="chip-slide">
12890          <div class="info-chip-val">IEEE</div>
12891          <div class="info-chip-label">1045-1992</div>
12892        </div>
12893      </div>
12894    </div>
12895
12896    {% if lan_ip.is_none() %}
12897    <div class="lan-local-hint">
12898      <strong>Want teammates on the same network to access this?</strong><br>
12899      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
12900    </div>
12901    {% endif %}
12902  </div>
12903
12904  <footer class="site-footer">
12905    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
12906    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12907    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12908    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12909    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12910  </footer>
12911
12912  <script nonce="{{ csp_nonce }}">
12913    (function () {
12914      var storageKey = 'oxide-sloc-theme';
12915      var body = document.body;
12916      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
12917      var toggle = document.getElementById('theme-toggle');
12918      if (toggle) toggle.addEventListener('click', function () {
12919        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
12920        body.classList.toggle('dark-theme', next === 'dark');
12921        try { localStorage.setItem(storageKey, next); } catch(e) {}
12922      });
12923      var copyBtn = document.getElementById('lan-copy-btn');
12924      if (copyBtn) copyBtn.addEventListener('click', function() {
12925        var btn = this;
12926        var el = document.getElementById('lan-url-val');
12927        if (!el) return;
12928        var url = el.textContent.trim();
12929        if (navigator.clipboard) {
12930          navigator.clipboard.writeText(url).then(function() {
12931            var orig = btn.innerHTML;
12932            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!';
12933            setTimeout(function() { btn.innerHTML = orig; }, 1800);
12934          });
12935        }
12936      });
12937      (function randomizeWatermarks() {
12938        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12939        if (!wms.length) return;
12940        var placed = [];
12941        function tooClose(top, left) {
12942          for (var i = 0; i < placed.length; i++) {
12943            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12944            if (dt < 16 && dl < 12) return true;
12945          }
12946          return false;
12947        }
12948        function pick(leftBand) {
12949          for (var attempt = 0; attempt < 50; attempt++) {
12950            var top = Math.random() * 88 + 2;
12951            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12952            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12953          }
12954          var top = Math.random() * 88 + 2;
12955          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12956          placed.push([top, left]); return [top, left];
12957        }
12958        var half = Math.floor(wms.length / 2);
12959        wms.forEach(function (img, i) {
12960          var pos = pick(i < half);
12961          var size = Math.floor(Math.random() * 100 + 120);
12962          var rot = (Math.random() * 360).toFixed(1);
12963          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12964          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;
12965        });
12966      })();
12967
12968      (function spawnCodeParticles() {
12969        var container = document.getElementById('code-particles');
12970        if (!container) return;
12971        var snippets = [
12972          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12973          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12974          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12975          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12976          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
12977        ];
12978        var count = 38;
12979        for (var i = 0; i < count; i++) {
12980          (function(idx) {
12981            var el = document.createElement('span');
12982            el.className = 'code-particle';
12983            var text = snippets[idx % snippets.length];
12984            el.textContent = text;
12985            var left = Math.random() * 94 + 2;
12986            var top = Math.random() * 88 + 6;
12987            var dur = (Math.random() * 10 + 9).toFixed(1);
12988            var delay = (Math.random() * 18).toFixed(1);
12989            var rot = (Math.random() * 26 - 13).toFixed(1);
12990            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12991            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
12992              + '--rot:' + rot + 'deg;--op:' + op + ';'
12993              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
12994            container.appendChild(el);
12995          })(i);
12996        }
12997      })();
12998      (function heroAnimations() {
12999        var sub = document.getElementById('hero-subtitle');
13000        if (sub) {
13001          var full = sub.textContent.trim();
13002          sub.textContent = '';
13003          sub.style.opacity = '1';
13004          var cursor = document.createElement('span');
13005          cursor.className = 'hero-cursor';
13006          sub.appendChild(cursor);
13007          var i = 0;
13008          setTimeout(function() {
13009            var iv = setInterval(function() {
13010              if (i < full.length) {
13011                sub.insertBefore(document.createTextNode(full[i]), cursor);
13012                i++;
13013              } else {
13014                clearInterval(iv);
13015                setTimeout(function() {
13016                  cursor.style.transition = 'opacity 1s ease';
13017                  cursor.style.opacity = '0';
13018                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
13019                }, 2400);
13020              }
13021            }, 11);
13022          }, 374);
13023        }
13024      })();
13025      (function logoBob() {
13026        var logo = document.querySelector('.hero-logo');
13027        var shadow = document.querySelector('.hero-logo-shadow');
13028        if (!logo) return;
13029        var cycleStart = null, cycleDur = 3600;
13030        var peakY = -14, peakScale = 1.07, peakRot = 0;
13031        function newCycle() {
13032          cycleDur = 3000 + Math.random() * 1840;
13033          peakY = -(9 + Math.random() * 13.8);
13034          peakScale = 1.04 + Math.random() * 0.081;
13035          peakRot = (Math.random() * 11.5 - 5.75);
13036        }
13037        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
13038        newCycle();
13039        function frame(ts) {
13040          if (cycleStart === null) cycleStart = ts;
13041          var t = (ts - cycleStart) / cycleDur;
13042          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
13043          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
13044          var y = peakY * phase;
13045          var sc = 1 + (peakScale - 1) * phase;
13046          var rot = peakRot * Math.sin(Math.PI * phase);
13047          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
13048          if (shadow) {
13049            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
13050            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
13051          }
13052          requestAnimationFrame(frame);
13053        }
13054        requestAnimationFrame(frame);
13055      })();
13056      (function mouseEffects() {
13057        var heroTitle = document.getElementById('hero-title');
13058        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
13059        function tick() {
13060          raf = null;
13061          if (heroTitle) {
13062            var r = heroTitle.getBoundingClientRect();
13063            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
13064            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
13065            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
13066          }
13067        }
13068        document.addEventListener('mousemove', function(e) {
13069          mx = e.clientX; my = e.clientY;
13070          if (!raf) raf = requestAnimationFrame(tick);
13071        });
13072        document.addEventListener('mouseleave', function() {
13073          if (heroTitle) {
13074            heroTitle.style.transition = 'transform 0.5s ease';
13075            heroTitle.style.transform = '';
13076            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
13077          }
13078        });
13079        document.querySelectorAll('.action-card').forEach(function(card) {
13080          card.addEventListener('mousemove', function(e) {
13081            var rect = card.getBoundingClientRect();
13082            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
13083            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
13084            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
13085            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
13086          });
13087          card.addEventListener('mouseleave', function() {
13088            card.style.transition = '';
13089            card.style.transform = '';
13090          });
13091        });
13092      })();
13093      (function chipSlideshow() {
13094        var slides = [
13095          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
13096          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
13097          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
13098          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
13099          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
13100        ];
13101        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
13102        var indices = [0,0,0,0,0];
13103        var paused = [false,false,false,false,false];
13104        chips.forEach(function(chip, i) {
13105          chip.addEventListener('mouseenter', function() { paused[i] = true; });
13106          chip.addEventListener('mouseleave', function() { paused[i] = false; });
13107        });
13108        function advance(i) {
13109          if (paused[i]) return;
13110          var chip = chips[i];
13111          var inner = chip.querySelector('.chip-slide');
13112          if (!inner) return;
13113          inner.classList.add('fading');
13114          setTimeout(function() {
13115            indices[i] = (indices[i] + 1) % slides[i].length;
13116            var s = slides[i][indices[i]];
13117            chip.querySelector('.info-chip-val').textContent = s.v;
13118            chip.querySelector('.info-chip-label').textContent = s.l;
13119            inner.classList.remove('fading');
13120          }, 720);
13121        }
13122        setInterval(function() {
13123          chips.forEach(function(chip, i) { advance(i); });
13124        }, 6000);
13125      })();
13126      (function cardLiveData() {
13127        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
13128          var el = document.getElementById('acp-scan-stat');
13129          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
13130        }).catch(function(){});
13131        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
13132          var el = document.getElementById('acp-test-stat');
13133          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
13134        }).catch(function(){});
13135        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
13136          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
13137          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
13138          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
13139          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
13140          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
13141          var stat = document.getElementById('acp-int-stat');
13142          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
13143        }).catch(function(){});
13144        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
13145          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
13146        }).catch(function(){});
13147      })();
13148    })();
13149  </script>
13150  <script nonce="{{ csp_nonce }}">
13151  (function(){
13152    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'}];
13153    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);});}
13154    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
13155    function init(){
13156      var btn=document.getElementById('settings-btn');if(!btn)return;
13157      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13158      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>';
13159      document.body.appendChild(m);
13160      var g=document.getElementById('scheme-grid');
13161      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);});
13162      var cl=document.getElementById('settings-close');
13163      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);
13164      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');});
13165      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
13166      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
13167    }
13168    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13169  }());
13170  </script>
13171</body>
13172</html>
13173"##,
13174    ext = "html"
13175)]
13176struct SplashTemplate {
13177    csp_nonce: String,
13178    server_mode: bool,
13179    lan_ip: Option<String>,
13180    port: u16,
13181    version: &'static str,
13182}
13183
13184// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
13185
13186#[derive(Template)]
13187#[template(
13188    source = r##"
13189<!doctype html>
13190<html lang="en">
13191<head>
13192  <meta charset="utf-8">
13193  <meta name="viewport" content="width=device-width, initial-scale=1">
13194  <title>OxideSLOC — Start a Scan</title>
13195  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13196  <style nonce="{{ csp_nonce }}">
13197    :root {
13198      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
13199      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13200      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13201      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13202      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
13203    }
13204    body.dark-theme {
13205      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
13206      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
13207    }
13208    *{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);}
13209    .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);}
13210    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
13211    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
13212    .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));}
13213    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
13214    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
13215    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
13216    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
13217    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13218    @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; } }
13219    .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;}
13220    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
13221    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
13222    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
13223    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
13224    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
13225    .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;}
13226    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13227    .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);}
13228    .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;}
13229    .settings-close:hover{color:var(--text);background:var(--surface-2);}
13230    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13231    .settings-modal-body{padding:14px 16px 16px;}
13232    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13233    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13234    .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;}
13235    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13236    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13237    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13238    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13239    .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;}
13240    .tz-select:focus{border-color:var(--oxide);}
13241    .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
13242    .page-header{text-align:center;margin-bottom:16px;}
13243    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
13244    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
13245    /* Cards */
13246    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
13247    .option-card-wrap{position:relative;}
13248    .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;}
13249    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
13250    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
13251    .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;}
13252    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
13253    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
13254    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
13255    .card-top-row{display:flex;align-items:center;gap:20px;}
13256    /* Two-column layout inside each card */
13257    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
13258    .card-left{display:flex;align-items:flex-start;min-width:0;}
13259    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
13260    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
13261    .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);}
13262    .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);}
13263    .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);}
13264    .card-text{min-width:0;}
13265    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
13266    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
13267    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
13268    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
13269    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
13270    /* Right CTA column */
13271    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
13272    .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;}
13273    /* Re-scan count badge */
13274    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
13275    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
13276    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
13277    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
13278    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
13279    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
13280    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
13281    body.dark-theme .btn-secondary{color:var(--oxide);}
13282    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
13283    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
13284    /* File input overlay — must be full-width so it aligns with other card-right buttons */
13285    .file-input-wrap{position:relative;width:100%;}
13286    .file-input-wrap .btn{width:100%;}
13287    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
13288    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13289    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
13290    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13291    .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;}
13292    @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));}}
13293    /* Recent list (card 3 — full-width section below header) */
13294    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
13295    .recent-list{display:flex;flex-direction:column;gap:8px;}
13296    .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;}
13297    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
13298    .recent-item-info{flex:1;min-width:0;}
13299    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
13300    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
13301    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
13302    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
13303    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13304    .site-footer a{color:var(--muted);}
13305    @media(max-width:680px){
13306      .card-body{grid-template-columns:1fr;}
13307      .card-right{flex-direction:row;flex-wrap:wrap;}
13308      .btn{flex:1;}
13309    }
13310    .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;}
13311    .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;}
13312    .server-online-pill{cursor:default;}
13313  </style>
13314</head>
13315<body>
13316  <div class="background-watermarks" aria-hidden="true">
13317    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13318    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13319    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13320    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13321    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13322    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13323    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13324  </div>
13325  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13326  <div class="top-nav">
13327    <div class="top-nav-inner">
13328      <a class="brand" href="/">
13329        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13330        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13331      </a>
13332      <div class="nav-right">
13333        <a class="nav-pill" href="/">Home</a>
13334        <div class="nav-dropdown">
13335          <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>
13336          <div class="nav-dropdown-menu">
13337            <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>
13338          </div>
13339        </div>
13340        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13341        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13342        <div class="nav-dropdown">
13343          <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>
13344          <div class="nav-dropdown-menu">
13345            <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>
13346          </div>
13347        </div>
13348        <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13349        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13350          <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>
13351        </button>
13352        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13353          <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>
13354          <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>
13355        </button>
13356      </div>
13357    </div>
13358  </div>
13359
13360  <div class="page">
13361    <div class="page-header">
13362      <h1>How would you like to scan?</h1>
13363      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
13364    </div>
13365
13366    <div class="option-grid">
13367
13368      <!-- Option 1: New scan -->
13369      <div class="option-card-wrap">
13370        <div class="option-card">
13371        <div class="option-icon new-scan">
13372          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
13373        </div>
13374        <div class="card-body">
13375          <div class="card-left">
13376            <div class="card-text">
13377              <div class="option-title">Start a new scan</div>
13378              <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>
13379              <ul class="feature-list">
13380                <li>Live project scope preview before you run</li>
13381                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
13382                <li>HTML, PDF, and JSON output — your choice</li>
13383              </ul>
13384            </div>
13385          </div>
13386          <div class="card-right">
13387            <a class="btn btn-primary" href="/scan">
13388              Configure &amp; scan
13389              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
13390            </a>
13391            <p class="card-tip">Full 4-step setup · all options</p>
13392          </div>
13393        </div>
13394        </div>
13395      </div>
13396
13397      <!-- Option 2: Load from config file -->
13398      <div class="option-card-wrap">
13399        <div class="option-card">
13400        <div class="option-icon load-config">
13401          <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>
13402        </div>
13403        <div class="card-body">
13404          <div class="card-left">
13405            <div class="card-text">
13406              <div class="option-title">Load a saved config</div>
13407              <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>
13408              <ul class="feature-list">
13409                <li>All 15 settings restored from the file</li>
13410                <li>Fully editable — change path or output dir</li>
13411                <li>Works with any scan-config.json</li>
13412              </ul>
13413            </div>
13414          </div>
13415          <div class="card-right">
13416            <div class="file-input-wrap">
13417              <button class="btn btn-secondary" id="load-config-btn" type="button">
13418                <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>
13419                Choose config file
13420              </button>
13421              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
13422            </div>
13423            <p class="card-tip" id="config-file-name">Exported after every scan</p>
13424          </div>
13425        </div>
13426        </div>
13427      </div>
13428
13429      <!-- Option 3: Re-scan recent project -->
13430      <div class="option-card-wrap">
13431        <div class="option-card" id="recent-card">
13432        <div class="card-top-row">
13433          <div class="option-icon rescan">
13434            <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>
13435          </div>
13436          <div class="card-body">
13437            <div class="card-left">
13438              <div class="card-text">
13439                <div class="option-title">Re-scan a recent project</div>
13440                <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>
13441                <ul class="feature-list">
13442                  <li>All 15+ settings restored from the saved config</li>
13443                  <li>Path and output dir are editable before running</li>
13444                  <li>Only scans with a saved config appear here</li>
13445                </ul>
13446              </div>
13447            </div>
13448            <div class="card-right">
13449              <div class="rescan-count-box">
13450                <div class="rescan-count-num" id="rescan-count-num">—</div>
13451                <div class="rescan-count-label">saved configs</div>
13452              </div>
13453              <a class="btn btn-secondary" href="/view-reports">
13454                <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>
13455                View all runs
13456              </a>
13457              <p class="card-tip">Opens run history</p>
13458            </div>
13459          </div>
13460        </div>
13461        <div class="section-divider"></div>
13462        <div class="recent-list" id="recent-list">
13463          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
13464        </div>
13465        </div>
13466      </div>
13467
13468    </div>
13469  </div>
13470
13471  <footer class="site-footer">
13472    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
13473    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13474    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13475    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13476    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13477  </footer>
13478
13479  <script nonce="{{ csp_nonce }}">
13480    (function () {
13481      var storageKey = 'oxide-sloc-theme';
13482      var body = document.body;
13483      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
13484      var toggle = document.getElementById('theme-toggle');
13485      if (toggle) toggle.addEventListener('click', function () {
13486        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
13487        body.classList.toggle('dark-theme', next === 'dark');
13488        try { localStorage.setItem(storageKey, next); } catch(e) {}
13489      });
13490
13491      (function randomizeWatermarks() {
13492        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13493        if (!wms.length) return;
13494        var placed = [];
13495        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; }
13496        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]; }
13497        var half = Math.floor(wms.length / 2);
13498        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; });
13499      })();
13500      (function spawnCodeParticles() {
13501        var container = document.getElementById('code-particles');
13502        if (!container) return;
13503        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'];
13504        var count = 38;
13505        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); }
13506      })();
13507      // Recent scans data injected from server
13508      var recentScans = {{ recent_scans_json|safe }};
13509
13510      function configToParams(cfg) {
13511        var p = new URLSearchParams();
13512        p.set('prefilled', '1');
13513        if (cfg.path) p.set('path', cfg.path);
13514        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
13515        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
13516        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
13517        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
13518        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
13519        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
13520        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
13521        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
13522        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
13523        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
13524        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
13525        if (cfg.report_title) p.set('report_title', cfg.report_title);
13526        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
13527        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
13528        return p;
13529      }
13530
13531      // Build recent scan list (capped at 3 visible entries)
13532      var list = document.getElementById('recent-list');
13533      var noNote = document.getElementById('no-recent-note');
13534      var hasAny = false;
13535      var MAX_RECENT = 3;
13536      if (Array.isArray(recentScans)) {
13537        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
13538        var shown = 0;
13539        validEntries.forEach(function (entry) {
13540          if (shown >= MAX_RECENT) return;
13541          shown++;
13542          hasAny = true;
13543          var item = document.createElement('div');
13544          item.className = 'recent-item';
13545          item.title = 'Restore all settings and open wizard';
13546          item.innerHTML =
13547            '<div class="recent-item-info">' +
13548              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
13549              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
13550            '</div>' +
13551            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
13552          item.addEventListener('click', function () {
13553            var params = configToParams(entry.config);
13554            window.location.href = '/scan?' + params.toString();
13555          });
13556          list.appendChild(item);
13557        });
13558        if (validEntries.length > MAX_RECENT) {
13559          var moreEl = document.createElement('div');
13560          moreEl.className = 'recent-more-link';
13561          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
13562          list.appendChild(moreEl);
13563        }
13564      }
13565      if (hasAny && noNote) noNote.style.display = 'none';
13566      // Update count badge
13567      var countEl = document.getElementById('rescan-count-num');
13568      if (countEl) {
13569        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
13570        countEl.textContent = total > 0 ? total : '0';
13571      }
13572
13573      // Config file loader
13574      var fileInput = document.getElementById('config-file-input');
13575      var fileName = document.getElementById('config-file-name');
13576      if (fileInput) {
13577        fileInput.addEventListener('change', function () {
13578          var file = fileInput.files && fileInput.files[0];
13579          if (!file) return;
13580          if (fileName) fileName.textContent = '✓ ' + file.name;
13581          var reader = new FileReader();
13582          reader.onload = function (e) {
13583            try {
13584              var cfg = JSON.parse(e.target.result);
13585              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
13586              var params = configToParams(cfg);
13587              window.location.href = '/scan?' + params.toString();
13588            } catch (err) {
13589              alert('Could not parse config file: ' + err.message);
13590            }
13591          };
13592          reader.readAsText(file);
13593        });
13594      }
13595
13596      function escHtml(s) {
13597        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
13598      }
13599    })();
13600  </script>
13601  <script nonce="{{ csp_nonce }}">
13602  (function(){
13603    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'}];
13604    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);});}
13605    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
13606    function init(){
13607      var btn=document.getElementById('settings-btn');if(!btn)return;
13608      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13609      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>';
13610      document.body.appendChild(m);
13611      var g=document.getElementById('scheme-grid');
13612      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);});
13613      var cl=document.getElementById('settings-close');
13614      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);
13615      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');});
13616      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
13617      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
13618    }
13619    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13620  }());
13621  </script>
13622</body>
13623</html>
13624"##,
13625    ext = "html"
13626)]
13627struct ScanSetupTemplate {
13628    version: &'static str,
13629    recent_scans_json: String,
13630    csp_nonce: String,
13631}
13632
13633#[derive(Template)]
13634#[template(
13635    source = r##"
13636<!doctype html>
13637<html lang="en">
13638<head>
13639  <meta charset="utf-8">
13640  <meta name="viewport" content="width=device-width, initial-scale=1">
13641  <title>OxideSLOC | {{ report_title }} | Report</title>
13642  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13643  <style nonce="{{ csp_nonce }}">
13644    :root {
13645      --radius: 18px;
13646      --bg: #f5efe8;
13647      --surface: rgba(255,255,255,0.82);
13648      --surface-2: #fbf7f2;
13649      --surface-3: #efe6dc;
13650      --line: #e6d0bf;
13651      --line-strong: #dcb89f;
13652      --text: #43342d;
13653      --muted: #7b675b;
13654      --muted-2: #a08777;
13655      --nav: #b85d33;
13656      --nav-2: #7a371b;
13657      --accent: #6f9bff;
13658      --accent-2: #4a78ee;
13659      --oxide: #d37a4c;
13660      --oxide-2: #b35428;
13661      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
13662      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
13663      --success-bg: #e8f5ed;
13664      --success-text: #1a8f47;
13665      --info-bg: #eef3ff;
13666      --info-text: #4467d8;
13667    }
13668
13669    body.dark-theme {
13670      --bg: #1b1511;
13671      --surface: #261c17;
13672      --surface-2: #2d221d;
13673      --surface-3: #372922;
13674      --line: #524238;
13675      --line-strong: #6c5649;
13676      --text: #f5ece6;
13677      --muted: #c7b7aa;
13678      --muted-2: #aa9485;
13679      --nav: #b85d33;
13680      --nav-2: #7a371b;
13681      --accent: #6f9bff;
13682      --accent-2: #4a78ee;
13683      --oxide: #d37a4c;
13684      --oxide-2: #b35428;
13685      --shadow: 0 18px 42px rgba(0,0,0,0.28);
13686      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
13687      --success-bg: #163927;
13688      --success-text: #8fe2a8;
13689      --info-bg: #1c2847;
13690      --info-text: #a9c1ff;
13691    }
13692
13693    * { box-sizing: border-box; }
13694    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); }
13695    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
13696    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
13697    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
13698    .top-nav, .page { position: relative; z-index: 2; }
13699    .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); }
13700    .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; }
13701    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
13702    .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)); }
13703    .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; }
13704    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
13705    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
13706    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
13707    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
13708    .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; }
13709    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
13710    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
13711    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
13712    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13713    @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; } }
13714    .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; }
13715    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
13716    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
13717    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
13718    .theme-toggle .icon-sun { display:none; }
13719    body.dark-theme .theme-toggle .icon-sun { display:block; }
13720    body.dark-theme .theme-toggle .icon-moon { display:none; }
13721    .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;}
13722    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13723    .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);}
13724    .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;}
13725    .settings-close:hover{color:var(--text);background:var(--surface-2);}
13726    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13727    .settings-modal-body{padding:14px 16px 16px;}
13728    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13729    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13730    .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;}
13731    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13732    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13733    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13734    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13735    .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;}
13736    .tz-select:focus{border-color:var(--oxide);}
13737    .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; }
13738    .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;}
13739    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
13740    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
13741    .hero, .panel { padding: 22px; }
13742    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
13743    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
13744    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
13745    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
13746    .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; }
13747    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
13748    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
13749    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
13750    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
13751    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
13752    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
13753    .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; }
13754    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
13755    .delta-card-val { font-size:16px; font-weight:800; }
13756    .delta-card-val.pos { color:#1e7e34; }
13757    .delta-card-val.neg { color:var(--neg); }
13758    .delta-card-val.mod { color:#b35428; }
13759    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
13760    .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; }
13761    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13762    .delta-card-inline:hover .delta-card-tip { opacity:1; }
13763    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
13764    .compare-ts { font-size:13px; color:var(--muted); }
13765    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
13766    .compare-arrow { color: var(--muted); }
13767    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
13768    .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; }
13769    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
13770    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
13771    .button, .copy-button {
13772      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;
13773    }
13774    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
13775    @keyframes spin { to { transform: rotate(360deg); } }
13776    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
13777    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
13778    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
13779    .path-item strong { display: block; margin-bottom: 6px; }
13780    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
13781    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
13782    .path-subitem { flex: 1; }
13783    .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); }
13784    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); }
13785    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
13786    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
13787    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
13788    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
13789    th { color: var(--muted); font-weight: 700; }
13790    tr:last-child td { border-bottom: none; }
13791    #subm-tbl col:nth-child(1){width:15%;}
13792    #subm-tbl col:nth-child(2){width:31%;}
13793    #subm-tbl col:nth-child(3){width:9%;}
13794    #subm-tbl col:nth-child(4){width:9%;}
13795    #subm-tbl col:nth-child(5){width:9%;}
13796    #subm-tbl col:nth-child(6){width:9%;}
13797    #subm-tbl col:nth-child(7){width:9%;}
13798    #subm-tbl col:nth-child(8){width:9%;}
13799    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
13800    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
13801    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
13802    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
13803    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
13804    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
13805    .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; }
13806    .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; }
13807    .soft-chip.success svg { flex:0 0 auto; }
13808    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); }
13809    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
13810    .muted { color: var(--muted); }
13811    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13812    .site-footer a{color:var(--muted);}
13813    .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; }
13814    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
13815    .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; }
13816    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
13817    /* Stat chips (matches HTML report) */
13818    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
13819    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
13820    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
13821    .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; }
13822    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
13823    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
13824    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
13825    .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; }
13826    .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; }
13827    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13828    .stat-chip:hover .stat-chip-tip { opacity:1; }
13829    /* Submodule panel */
13830    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
13831    /* Metrics tables stack */
13832    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
13833    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
13834    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
13835    .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)); }
13836    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
13837    /* Metrics table */
13838    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
13839    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
13840    .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; }
13841    .metrics-table thead th:not(:first-child) { text-align: right; }
13842    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
13843    .metrics-table tbody tr:last-child td { border-bottom: none; }
13844    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
13845    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
13846    .metrics-table tbody tr:hover td { background: var(--surface-2); }
13847    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
13848    .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; }
13849    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
13850    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
13851    .mt-val-pos { color: var(--pos); font-weight: 700; }
13852    .mt-val-neg { color: var(--neg); font-weight: 700; }
13853    .mt-val-zero { color: var(--muted); }
13854    .mt-val-mod { color: var(--oxide-2); }
13855    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
13856    @media (max-width: 1180px) {
13857      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
13858      .nav-project-slot, .nav-status { justify-content:flex-start; }
13859      .hero-top { flex-direction: column; }
13860    }
13861    .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;}
13862    @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));}}
13863    .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;}
13864    /* ── Result-page chart controls ─────────────────────────────────────────── */
13865    .r-chart-section{margin-bottom:24px;}
13866    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
13867    .section-pair > .panel{flex-shrink:0;}
13868    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
13869    .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;}
13870    .r-chart-select:focus{border-color:var(--accent);}
13871    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
13872    .r-chart-container svg{display:block;width:100%;height:auto;}
13873    .r-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
13874    .r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
13875    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
13876    .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;}
13877    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
13878    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
13879    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
13880    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
13881    #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;}
13882    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
13883    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
13884    .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;}
13885    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
13886    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
13887    .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface-2);display:flex;flex-direction:column;}
13888    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
13889    .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%;}
13890    .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%;}
13891    body.has-report-banner .top-nav{top:27px;}
13892    body.has-report-banner{padding-bottom:27px;}
13893  </style>
13894</head>
13895<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
13896  <div class="background-watermarks" aria-hidden="true">
13897    <img src="/images/logo/logo-text.png" alt="" />
13898    <img src="/images/logo/logo-text.png" alt="" />
13899    <img src="/images/logo/logo-text.png" alt="" />
13900    <img src="/images/logo/logo-text.png" alt="" />
13901    <img src="/images/logo/logo-text.png" alt="" />
13902    <img src="/images/logo/logo-text.png" alt="" />
13903    <img src="/images/logo/logo-text.png" alt="" />
13904    <img src="/images/logo/logo-text.png" alt="" />
13905    <img src="/images/logo/logo-text.png" alt="" />
13906    <img src="/images/logo/logo-text.png" alt="" />
13907    <img src="/images/logo/logo-text.png" alt="" />
13908    <img src="/images/logo/logo-text.png" alt="" />
13909    <img src="/images/logo/logo-text.png" alt="" />
13910    <img src="/images/logo/logo-text.png" alt="" />
13911  </div>
13912  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13913  {% if let Some(banner) = report_header_footer %}
13914  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
13915  {% endif %}
13916  <div class="top-nav">
13917    <div class="top-nav-inner">
13918      <a class="brand" href="/">
13919        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13920        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13921      </a>
13922      <div class="nav-project-slot">
13923        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
13924      </div>
13925      <div class="nav-status">
13926        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
13927        <div class="nav-dropdown">
13928          <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>
13929          <div class="nav-dropdown-menu">
13930            <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>
13931          </div>
13932        </div>
13933        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
13934        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13935        <div class="nav-dropdown">
13936          <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>
13937          <div class="nav-dropdown-menu">
13938            <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>
13939          </div>
13940        </div>
13941        <div class="server-status-wrap">
13942          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13943          <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>
13944        </div>
13945        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13946          <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>
13947        </button>
13948        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13949          <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>
13950          <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>
13951        </button>
13952      </div>
13953    </div>
13954  </div>
13955
13956  <div class="page">
13957    <section class="hero">
13958      <div class="hero-top">
13959        <div>
13960          <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>
13961          <h1 class="hero-title">{{ report_title }}</h1>
13962          <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>
13963        </div>
13964        <div class="hero-quick-actions">
13965          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
13966          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
13967          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
13968        </div>
13969      </div>
13970
13971      <div class="summary-strip">
13972        <div class="stat-chip" data-raw="{{ physical_lines }}">
13973          <div class="stat-chip-label">Physical Lines</div>
13974          <div class="stat-chip-val">{{ physical_lines }}</div>
13975          <div class="stat-chip-exact"></div>
13976          <div class="stat-chip-tip">Total physical lines including code, comments, and blank lines</div>
13977        </div>
13978        <div class="stat-chip" data-raw="{{ code_lines }}">
13979          <div class="stat-chip-label">Code</div>
13980          <div class="stat-chip-val">{{ code_lines }}</div>
13981          <div class="stat-chip-exact"></div>
13982          <div class="stat-chip-tip">Executable source lines (IEEE 1045 SLOC)</div>
13983        </div>
13984        <div class="stat-chip" data-raw="{{ comment_lines }}">
13985          <div class="stat-chip-label">Comments</div>
13986          <div class="stat-chip-val">{{ comment_lines }}</div>
13987          <div class="stat-chip-exact"></div>
13988          <div class="stat-chip-tip">Lines classified as comments or documentation</div>
13989        </div>
13990        <div class="stat-chip" data-raw="{{ blank_lines }}">
13991          <div class="stat-chip-label">Blank</div>
13992          <div class="stat-chip-val">{{ blank_lines }}</div>
13993          <div class="stat-chip-exact"></div>
13994          <div class="stat-chip-tip">Empty or whitespace-only lines</div>
13995        </div>
13996        <div class="stat-chip" data-raw="{{ files_analyzed }}">
13997          <div class="stat-chip-label">Files Analyzed</div>
13998          <div class="stat-chip-val">{{ files_analyzed }}</div>
13999          <div class="stat-chip-exact"></div>
14000          <div class="stat-chip-tip">Source files successfully parsed and counted</div>
14001        </div>
14002        <div class="stat-chip" data-raw="{{ functions }}">
14003          <div class="stat-chip-label">Functions</div>
14004          <div class="stat-chip-val">{{ functions }}</div>
14005          <div class="stat-chip-exact"></div>
14006          <div class="stat-chip-tip">Best-effort count of function and method definitions</div>
14007        </div>
14008      </div>
14009
14010      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
14011      <div class="compare-banner">
14012        <div class="compare-banner-body">
14013          <div class="compare-banner-meta">
14014            <span class="compare-label">Previous scan</span>
14015            <span class="compare-ts">{{ prev_ts }}</span>
14016            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
14017            {% if let Some(prev_code) = prev_run_code_lines %}
14018            <div class="compare-banner-stats" style="margin-top:4px;">
14019              <span>Code before: <strong>{{ prev_code }}</strong></span>
14020              <span class="compare-arrow">→</span>
14021              <span>Code now: <strong>{{ code_lines }}</strong></span>
14022              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
14023              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
14024            </div>
14025            {% endif %}
14026          </div>
14027          {% if delta_lines_added.is_some() %}
14028          <div class="delta-cards-inline">
14029            <div class="delta-card-inline">
14030              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
14031              <div class="delta-card-lbl">lines added</div>
14032              <div class="delta-card-tip">Code lines added since the previous scan</div>
14033            </div>
14034            <div class="delta-card-inline">
14035              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
14036              <div class="delta-card-lbl">lines removed</div>
14037              <div class="delta-card-tip">Code lines removed since the previous scan</div>
14038            </div>
14039            <div class="delta-card-inline">
14040              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
14041              <div class="delta-card-lbl">unmodified lines</div>
14042              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
14043            </div>
14044            <div class="delta-card-inline">
14045              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
14046              <div class="delta-card-lbl">files modified</div>
14047              <div class="delta-card-tip">Files with at least one line changed</div>
14048            </div>
14049            <div class="delta-card-inline">
14050              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
14051              <div class="delta-card-lbl">files added</div>
14052              <div class="delta-card-tip">New files added since the previous scan</div>
14053            </div>
14054            <div class="delta-card-inline">
14055              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
14056              <div class="delta-card-lbl">files removed</div>
14057              <div class="delta-card-tip">Files deleted since the previous scan</div>
14058            </div>
14059            <div class="delta-card-inline">
14060              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
14061              <div class="delta-card-lbl">files unchanged</div>
14062              <div class="delta-card-tip">Files with no changes since the previous scan</div>
14063            </div>
14064          </div>
14065          {% else %}
14066          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
14067            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
14068          </p>
14069          {% endif %}
14070          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
14071        </div>
14072      </div>
14073      {% endif %}{% endif %}
14074
14075      <div class="action-grid">
14076        <div class="action-card">
14077          <h3>HTML report</h3>
14078          <div class="action-buttons">
14079            {% match html_url %}
14080              {% when Some with (url) %}
14081                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
14082              {% when None %}{% endmatch %}
14083            {% match html_download_url %}
14084              {% when Some with (url) %}
14085                <a class="button secondary" href="{{ url }}">Download HTML</a>
14086              {% when None %}{% endmatch %}
14087            {% match html_path %}
14088              {% when Some with (_path) %}{% when None %}{% endmatch %}
14089            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
14090          </div>
14091        </div>
14092        <div class="action-card">
14093          <h3>PDF report</h3>
14094          <div class="action-buttons">
14095            {% match pdf_url %}
14096              {% when Some with (url) %}
14097                {% if pdf_generating %}
14098                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
14099                    <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>
14100                    Generating PDF…
14101                  </button>
14102                {% else %}
14103                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
14104                {% endif %}
14105              {% when None %}{% endmatch %}
14106            {% match pdf_download_url %}
14107              {% when Some with (url) %}
14108                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
14109              {% when None %}{% endmatch %}
14110            {% match pdf_path %}
14111              {% when Some with (_path) %}{% when None %}{% endmatch %}
14112            <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
14113          </div>
14114        </div>
14115        <div class="action-card">
14116          <h3>JSON result</h3>
14117          <div class="action-buttons">
14118            {% match json_url %}
14119              {% when Some with (url) %}
14120                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
14121              {% when None %}{% endmatch %}
14122            {% match json_download_url %}
14123              {% when Some with (url) %}
14124                <a class="button secondary" href="{{ url }}">Download JSON</a>
14125              {% when None %}{% endmatch %}
14126            {% match json_path %}
14127              {% when Some with (_path) %}
14128                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
14129              {% when None %}
14130                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
14131              {% endmatch %}
14132          </div>
14133        </div>
14134        <div class="action-card">
14135          <h3>Scan config</h3>
14136          <div class="action-buttons">
14137            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
14138            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
14139            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
14140          </div>
14141        </div>
14142        {% if confluence_configured %}
14143        <div class="action-card" id="confluenceCard">
14144          <h3>Confluence</h3>
14145          <div class="action-buttons">
14146            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
14147            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
14148          </div>
14149          <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>
14150        </div>
14151        {% endif %}
14152      </div>
14153      {% if confluence_configured %}
14154      <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;">
14155        <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);">
14156          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
14157          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
14158          <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;">
14159          <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>
14160          <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;">
14161          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
14162          <div style="display:flex;gap:10px;justify-content:flex-end;">
14163            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
14164            <button class="button" id="confSubmitBtn" type="button">Post</button>
14165          </div>
14166        </div>
14167      </div>
14168      {% endif %}
14169      {% if !submodule_rows.is_empty() %}
14170      <div class="submodule-panel">
14171        <div class="toolbar-row">
14172          <div>
14173            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
14174            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
14175          </div>
14176          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
14177        </div>
14178        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
14179        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
14180          <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>
14181          <thead>
14182            <tr>
14183              <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>
14184              <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>
14185              <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>
14186              <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>
14187              <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>
14188              <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>
14189              <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>
14190              <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>
14191            </tr>
14192          </thead>
14193          <tbody>
14194            {% for row in submodule_rows %}
14195            <tr>
14196              <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>
14197              <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>
14198              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
14199              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
14200              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
14201              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
14202              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
14203              <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>
14204            </tr>
14205            {% endfor %}
14206          </tbody>
14207        </table>
14208        </div>
14209      </div>
14210      {% endif %}
14211
14212      <div class="metrics-tables-stack">
14213
14214        <div class="metrics-table-wrap">
14215          <div class="metrics-table-title">Files</div>
14216          <table class="metrics-table">
14217            <thead>
14218              <tr>
14219                <th>Metric</th>
14220                <th>This Run</th>
14221                <th>Previous</th>
14222                <th>Change</th>
14223              </tr>
14224            </thead>
14225            <tbody>
14226              <tr>
14227                <td>Files analyzed</td>
14228                <td class="mt-val-large">{{ files_analyzed }}</td>
14229                <td>{{ prev_fa_str }}</td>
14230                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
14231              </tr>
14232              <tr>
14233                <td>Files skipped</td>
14234                <td>{{ files_skipped }}</td>
14235                <td>{{ prev_fs_str }}</td>
14236                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
14237              </tr>
14238              <tr>
14239                <td>Files modified</td>
14240                <td class="mt-val-na">—</td>
14241                <td class="mt-val-na">—</td>
14242                <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>
14243              </tr>
14244              <tr>
14245                <td>Files unchanged</td>
14246                <td class="mt-val-na">—</td>
14247                <td class="mt-val-na">—</td>
14248                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
14249              </tr>
14250            </tbody>
14251          </table>
14252        </div>
14253
14254        <div class="metrics-table-wrap">
14255          <div class="metrics-table-title">Line Counts</div>
14256          <table class="metrics-table">
14257            <thead>
14258              <tr>
14259                <th>Metric</th>
14260                <th>This Run</th>
14261                <th>Previous</th>
14262                <th>Change</th>
14263              </tr>
14264            </thead>
14265            <tbody>
14266              <tr>
14267                <td>Physical lines</td>
14268                <td class="mt-val-large">{{ physical_lines }}</td>
14269                <td>{{ prev_pl_str }}</td>
14270                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
14271              </tr>
14272              <tr>
14273                <td>Code lines</td>
14274                <td class="mt-val-large">{{ code_lines }}</td>
14275                <td>{{ prev_cl_str }}</td>
14276                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
14277              </tr>
14278              <tr>
14279                <td>Comment lines</td>
14280                <td>{{ comment_lines }}</td>
14281                <td>{{ prev_cml_str }}</td>
14282                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
14283              </tr>
14284              <tr>
14285                <td>Blank lines</td>
14286                <td>{{ blank_lines }}</td>
14287                <td>{{ prev_bl_str }}</td>
14288                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
14289              </tr>
14290              <tr>
14291                <td>Mixed (separate)</td>
14292                <td>{{ mixed_lines }}</td>
14293                <td class="mt-val-na">—</td>
14294                <td class="mt-val-na">—</td>
14295              </tr>
14296            </tbody>
14297          </table>
14298        </div>
14299
14300        <div class="metrics-tables-lower">
14301          <div class="metrics-table-wrap">
14302            <div class="metrics-table-title">Code Structure</div>
14303            <table class="metrics-table">
14304              <thead>
14305                <tr>
14306                  <th>Metric</th>
14307                  <th>This Run</th>
14308                </tr>
14309              </thead>
14310              <tbody>
14311                <tr>
14312                  <td>Functions</td>
14313                  <td>{{ functions }}</td>
14314                </tr>
14315                <tr>
14316                  <td>Classes / Types</td>
14317                  <td>{{ classes }}</td>
14318                </tr>
14319                <tr>
14320                  <td>Variables</td>
14321                  <td>{{ variables }}</td>
14322                </tr>
14323                <tr>
14324                  <td>Imports</td>
14325                  <td>{{ imports }}</td>
14326                </tr>
14327              </tbody>
14328            </table>
14329          </div>
14330
14331          <div class="metrics-table-wrap">
14332            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
14333            <table class="metrics-table">
14334              <thead>
14335                <tr>
14336                  <th>Metric</th>
14337                  <th>Change</th>
14338                </tr>
14339              </thead>
14340              <tbody>
14341                <tr>
14342                  <td>Lines added</td>
14343                  <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>
14344                </tr>
14345                <tr>
14346                  <td>Lines removed</td>
14347                  <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>
14348                </tr>
14349                <tr>
14350                  <td>Lines modified (net)</td>
14351                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
14352                </tr>
14353                <tr>
14354                  <td>Lines unmodified</td>
14355                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
14356                </tr>
14357              </tbody>
14358            </table>
14359          </div>
14360        </div>
14361
14362      </div>
14363
14364      <div class="path-list">
14365        <div class="path-item">
14366          <div class="path-item-label">Project path</div>
14367          <code>{{ project_path }}</code>
14368        </div>
14369        <div class="path-item">
14370          <div class="path-item-label">Git branch</div>
14371          {% if let Some(branch) = git_branch %}
14372          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
14373          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
14374          {% else %}
14375          <code style="color:var(--muted)">—</code>
14376          {% endif %}
14377        </div>
14378        <div class="path-item">
14379          <div class="path-item-label">Output folder</div>
14380          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
14381        </div>
14382        <div class="path-item">
14383          <div class="path-item-label">Run ID</div>
14384          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
14385            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
14386            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
14387          </div>
14388        </div>
14389      </div>
14390    </section>
14391
14392    <div id="r-tt" aria-hidden="true"></div>
14393
14394    <div class="section-pair">
14395    <section class="panel">
14396        <div class="toolbar-row">
14397          <div>
14398            <h2>Language breakdown</h2>
14399            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
14400          </div>
14401        </div>
14402        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
14403    </section>
14404
14405    <section class="panel r-chart-section">
14406      <div class="toolbar-row" style="margin-bottom:16px;">
14407        <div>
14408          <h2>Visualizations</h2>
14409          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
14410        </div>
14411      </div>
14412
14413      <div class="r-viz-grid">
14414        <div class="r-viz-card">
14415          <p class="r-viz-card-title">Language Composition</p>
14416          <div class="r-chart-tab-bar">
14417            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
14418            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
14419          </div>
14420          <div class="r-chart-container" id="r-composition-chart"></div>
14421        </div>
14422        <div class="r-viz-card">
14423          <p class="r-viz-card-title">Files vs Code Lines</p>
14424          <div class="r-chart-container" id="r-scatter-chart"></div>
14425        </div>
14426        {% if has_semantic_data %}
14427        <div class="r-viz-card">
14428          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14429            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
14430            <select class="r-chart-select" id="r-semantic-metric">
14431              <option value="functions">Functions</option>
14432              <option value="classes">Classes</option>
14433              <option value="variables">Variables</option>
14434              <option value="imports">Imports</option>
14435            </select>
14436          </div>
14437          <div class="r-chart-container" id="r-semantic-chart"></div>
14438        </div>
14439        {% endif %}
14440        {% if has_submodule_data %}
14441        <div class="r-viz-card">
14442          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14443            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Submodule Breakdown</p>
14444            <select class="r-chart-select" id="r-sub-metric">
14445              <option value="code">Code Lines</option>
14446              <option value="comment">Comments</option>
14447              <option value="blank">Blank Lines</option>
14448              <option value="physical">Physical Lines</option>
14449              <option value="files">Files</option>
14450            </select>
14451            <select class="r-chart-select" id="r-sub-sort">
14452              <option value="desc">Value ↓</option>
14453              <option value="asc">Value ↑</option>
14454              <option value="name">Name A→Z</option>
14455            </select>
14456          </div>
14457          <div class="r-chart-container" id="r-submodule-chart"></div>
14458        </div>
14459        {% endif %}
14460      </div>
14461
14462    </section>
14463    </div>
14464
14465  </div>
14466
14467  <script nonce="{{ csp_nonce }}">
14468    (function () {
14469      var body = document.body;
14470      var themeToggle = document.getElementById('theme-toggle');
14471      var storageKey = 'oxide-sloc-theme';
14472
14473      function applyTheme(theme) {
14474        body.classList.toggle('dark-theme', theme === 'dark');
14475      }
14476
14477      function loadSavedTheme() {
14478        try {
14479          var saved = localStorage.getItem(storageKey);
14480          if (saved === 'dark' || saved === 'light') {
14481            applyTheme(saved);
14482          }
14483        } catch (e) {}
14484      }
14485
14486      if (themeToggle) {
14487        themeToggle.addEventListener('click', function () {
14488          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
14489          applyTheme(nextTheme);
14490          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
14491        });
14492      }
14493
14494      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
14495        button.addEventListener('click', function () {
14496          var value = button.getAttribute('data-copy-value') || '';
14497          if (!value) return;
14498          if (navigator.clipboard && navigator.clipboard.writeText) {
14499            navigator.clipboard.writeText(value).catch(function () {});
14500          }
14501        });
14502      });
14503
14504      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14505        btn.addEventListener('click', function () {
14506          var folder = btn.getAttribute('data-folder') || '';
14507          if (!folder) return;
14508          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
14509        });
14510      });
14511
14512      loadSavedTheme();
14513
14514      // ── Compact number formatting for stat chips ──────────────────────────
14515      (function(){
14516        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();}
14517        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
14518          var raw=parseInt(chip.getAttribute('data-raw'),10);
14519          if(isNaN(raw))return;
14520          var valEl=chip.querySelector('.stat-chip-val');
14521          if(valEl)valEl.textContent=fmt(raw);
14522          var exactEl=chip.querySelector('.stat-chip-exact');
14523          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
14524        });
14525      })();
14526
14527      // ── Shared tooltip for all result-page charts ─────────────────────────
14528      var rTT=(function(){
14529        var el=document.getElementById('r-tt');
14530        if(!el)return{s:function(){},h:function(){},m:function(){}};
14531        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
14532        function hide(){el.style.display='none';}
14533        function move(e){
14534          var x=e.clientX+16,y=e.clientY-12;
14535          var r=el.getBoundingClientRect();
14536          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
14537          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
14538          el.style.left=x+'px';el.style.top=y+'px';
14539        }
14540        return{s:show,h:hide,m:move};
14541      })();
14542      window.rTT=rTT;
14543
14544      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
14545      (function(){
14546        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14547        document.addEventListener('mouseover',function(e){
14548          var t=e.target;
14549          while(t&&t.getAttribute){
14550            var l=t.getAttribute('data-ttl');
14551            if(l!==null){
14552              var v=t.getAttribute('data-ttv')||'';
14553              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
14554              return;
14555            }
14556            t=t.parentNode;
14557          }
14558        });
14559        document.addEventListener('mouseout',function(e){
14560          var t=e.target;
14561          while(t&&t.getAttribute){
14562            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
14563            t=t.parentNode;
14564          }
14565        });
14566        document.addEventListener('mousemove',function(e){
14567          var el=document.getElementById('r-tt');
14568          if(el&&el.style.display!=='none')rTT.m(e);
14569        });
14570      })();
14571
14572      // ── Language overview charts ───────────────────────────────────────────
14573      (function(){
14574        var D={{ lang_chart_json|safe }};
14575        if(!D||!D.length)return;
14576        var el=document.getElementById('result-lang-charts');
14577        if(!el)return;
14578        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14579        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
14580        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14581        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();}
14582        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14583        function px(n){return Math.round(n);}
14584        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+'"';}
14585        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
14586
14587        // Donut chart — fixed 240×240 viewBox, legend to the right inside the SVG
14588        var cx=100,cy=110,Ro=88,Ri=48;
14589        var legX=204,DW=360,DH=220;
14590        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">';
14591        if(D.length===1){
14592          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
14593          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+'"/>';
14594        } else {
14595          var ang=-Math.PI/2;
14596          D.forEach(function(d,i){
14597            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
14598            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
14599            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
14600            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
14601            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
14602            var pct=Math.round(d.code/tot*100);
14603            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"/>';
14604            ang+=sw;
14605          });
14606        }
14607        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
14608        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
14609        var legRows=Math.min(D.length,8);
14610        var legYStart=Math.round((DH-legRows*22)/2);
14611        D.forEach(function(d,i){
14612          if(i>=8)return;
14613          var ly=legYStart+i*22;
14614          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
14615          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
14616        });
14617        ds+='</svg>';
14618
14619        // Horizontal stacked-bar chart — fills container width
14620        var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
14621        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
14622        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">';
14623        D.forEach(function(d,i){
14624          var y=6+i*rHb,x=LW;
14625          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
14626          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>';
14627          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;
14628          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;
14629          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"/>';
14630          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>';
14631        });
14632        var ly=SH-14;
14633        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>';
14634        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>';
14635        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>';
14636        bs+='</svg>';
14637        el.innerHTML='<div class="r-lang-overview">'+
14638          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
14639          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
14640        '</div>';
14641      })();
14642
14643      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
14644      (function(){
14645        var LANG_D={{ lang_chart_json|safe }};
14646        var SCAT_D={{ scatter_chart_json|safe }};
14647        var SEM_D={{ semantic_chart_json|safe }};
14648        var SUB_D={{ submodule_chart_json|safe }};
14649        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
14650        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14651        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();}
14652        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14653        function px(n){return Math.round(n);}
14654        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+'"';}
14655
14656        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
14657        function renderComposition(mode){
14658          var el=document.getElementById('r-composition-chart');
14659          if(!el||!LANG_D||!LANG_D.length)return;
14660          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14661          var LW=110,SH=224;
14662          var svgW=Math.max(320,el.offsetWidth||480);
14663          var BW=Math.max(120,svgW-LW-80);
14664          var legendH=24,topPad=4;
14665          var n=LANG_D.length||1;
14666          var rowTotal=Math.floor((SH-legendH-topPad)/n);
14667          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
14668          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">';
14669          if(mode==='pct'){
14670            LANG_D.forEach(function(d,i){
14671              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
14672              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
14673              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14674              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>';
14675              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;
14676              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;
14677              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+'"/>';
14678              var pct=Math.round((d.code||0)/tot2*100);
14679              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
14680            });
14681          } else {
14682            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
14683            LANG_D.forEach(function(d,i){
14684              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
14685              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14686              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>';
14687              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;
14688              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;
14689              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+'"/>';
14690              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>';
14691            });
14692          }
14693          var ly=SH-legendH+4;
14694          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>';
14695          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>';
14696          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>';
14697          s+='</svg>';
14698          el.innerHTML=s;
14699        }
14700        renderComposition('abs');
14701        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
14702          btn.addEventListener('click',function(){
14703            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
14704            btn.classList.add('active');
14705            renderComposition(btn.getAttribute('data-rcomp'));
14706          });
14707        });
14708
14709        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
14710        (function(){
14711          var el=document.getElementById('r-scatter-chart');
14712          if(!el||!SCAT_D||!SCAT_D.length)return;
14713          var H=224,PL=52,PB=36,PT=12,PR=14;
14714          var W=Math.max(320,el.offsetWidth||480);
14715          var cW=W-PL-PR,cH=H-PT-PB;
14716          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14717          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14718          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14719          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">';
14720          [0,0.25,0.5,0.75,1].forEach(function(t){
14721            var y=PT+cH*(1-t);
14722            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14723            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>';
14724          });
14725          [0,0.25,0.5,0.75,1].forEach(function(t){
14726            var x=PL+cW*t;
14727            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14728            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>';
14729          });
14730          SCAT_D.forEach(function(d,i){
14731            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
14732            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
14733            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"/>';
14734            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>';
14735          });
14736          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>';
14737          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>';
14738          s+='</svg>';
14739          el.innerHTML=s;
14740        })();
14741
14742        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
14743        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
14744        // the old vertical column layout on wide containers.
14745        function renderSemantic(key){
14746          var el=document.getElementById('r-semantic-chart');
14747          if(!el||!SEM_D||!SEM_D.length)return;
14748          var LW=112,SH=224;
14749          var svgW=Math.max(320,el.offsetWidth||480);
14750          var BW=Math.max(120,svgW-LW-80);
14751          var topPad=4,botPad=14;
14752          var n2=SEM_D.length||1;
14753          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
14754          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
14755          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
14756          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">';
14757          SEM_D.forEach(function(d,i){
14758            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
14759            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>';
14760            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"/>';
14761            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>';
14762          });
14763          s+='</svg>';
14764          el.innerHTML=s;
14765        }
14766        var semSel=document.getElementById('r-semantic-metric');
14767        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
14768
14769        // ── Submodule: horizontal bar chart ────────────────────────────────────
14770        function renderSubmodule(key,sort){
14771          var el=document.getElementById('r-submodule-chart');
14772          if(!el||!SUB_D||!SUB_D.length)return;
14773          var data=SUB_D.slice();
14774          if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
14775          else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
14776          else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
14777          var LW=128,SH=224;
14778          var svgW=Math.max(320,el.offsetWidth||480);
14779          var BW=Math.max(120,svgW-LW-80);
14780          var topPad3=4,botPad3=14;
14781          var n3=data.length||1;
14782          var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
14783          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
14784          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
14785          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">';
14786          data.forEach(function(d,i){
14787            var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-bH)/2);
14788            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>';
14789            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"/>';
14790            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>';
14791          });
14792          s+='</svg>';
14793          el.innerHTML=s;
14794        }
14795        var subSel=document.getElementById('r-sub-metric');
14796        var sortSel=document.getElementById('r-sub-sort');
14797        if(subSel){
14798          renderSubmodule('code','desc');
14799          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
14800          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
14801        }
14802
14803        // Re-render all SVG charts when the window is resized so bars fill the card.
14804        var _rResizeTimer;
14805        window.addEventListener('resize',function(){
14806          clearTimeout(_rResizeTimer);
14807          _rResizeTimer=setTimeout(function(){
14808            var rcompBtn=document.querySelector('[data-rcomp].active');
14809            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
14810            (function(){
14811              var scEl=document.getElementById('r-scatter-chart');
14812              if(!scEl||!SCAT_D||!SCAT_D.length)return;
14813              var H=224,PL=52,PB=36,PT=12,PR=14;
14814              var W=Math.max(320,scEl.offsetWidth||480);
14815              var cW=W-PL-PR,cH=H-PT-PB;
14816              var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14817              var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14818              var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14819              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">';
14820              [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>';});
14821              [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>';});
14822              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>';});
14823              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>';
14824              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>';
14825              s+='</svg>';scEl.innerHTML=s;
14826            })();
14827            if(semSel)renderSemantic(semSel.value||'functions');
14828            if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
14829          },120);
14830        });
14831      })();
14832
14833      (function randomizeWatermarks() {
14834        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14835        if (!wms.length) return;
14836        var placed = [];
14837        function tooClose(top, left) {
14838          for (var i = 0; i < placed.length; i++) {
14839            var dt = Math.abs(placed[i][0] - top);
14840            var dl = Math.abs(placed[i][1] - left);
14841            if (dt < 20 && dl < 18) return true;
14842          }
14843          return false;
14844        }
14845        function pick(leftBand) {
14846          for (var attempt = 0; attempt < 50; attempt++) {
14847            var top = Math.random() * 85 + 5;
14848            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14849            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14850          }
14851          var top = Math.random() * 85 + 5;
14852          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14853          placed.push([top, left]);
14854          return [top, left];
14855        }
14856        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
14857        var half = Math.floor(wms.length / 2);
14858        wms.forEach(function (img, i) {
14859          var pos = pick(i < half);
14860          var size = Math.floor(Math.random() * 100 + 160);
14861          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
14862          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
14863          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;
14864        });
14865      })();
14866
14867      (function spawnCodeParticles() {
14868        var container = document.getElementById('code-particles');
14869        if (!container) return;
14870        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'];
14871        for (var i = 0; i < 38; i++) {
14872          (function(idx) {
14873            var el = document.createElement('span');
14874            el.className = 'code-particle';
14875            el.textContent = snippets[idx % snippets.length];
14876            var left = Math.random() * 94 + 2;
14877            var top = Math.random() * 88 + 6;
14878            var dur = (Math.random() * 10 + 9).toFixed(1);
14879            var delay = (Math.random() * 18).toFixed(1);
14880            var rot = (Math.random() * 26 - 13).toFixed(1);
14881            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14882            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';
14883            container.appendChild(el);
14884          })(i);
14885        }
14886      })();
14887
14888      {% if pdf_generating %}
14889      // Poll for PDF readiness and swap the disabled button to a live link once done.
14890      (function() {
14891        var openBtn = document.getElementById('pdf-open-btn');
14892        var dlBtn = document.getElementById('pdf-download-btn');
14893        function checkPdf() {
14894          fetch('/api/runs/{{ run_id }}/pdf-status')
14895            .then(function(r) { return r.json(); })
14896            .then(function(d) {
14897              if (d.ready) {
14898                if (openBtn) {
14899                  var a = document.createElement('a');
14900                  a.className = 'button';
14901                  a.id = 'pdf-open-btn';
14902                  a.href = '/runs/pdf/{{ run_id }}';
14903                  a.target = '_blank';
14904                  a.rel = 'noopener';
14905                  a.textContent = 'Open PDF';
14906                  openBtn.replaceWith(a);
14907                }
14908                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
14909              } else {
14910                setTimeout(checkPdf, 3000);
14911              }
14912            })
14913            .catch(function() { setTimeout(checkPdf, 5000); });
14914        }
14915        setTimeout(checkPdf, 3000);
14916      })();
14917      {% endif %}
14918
14919    })();
14920  </script>
14921  <script nonce="{{ csp_nonce }}">
14922  (function(){
14923    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'}];
14924    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);});}
14925    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14926    function init(){
14927      var btn=document.getElementById('settings-btn');if(!btn)return;
14928      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14929      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>';
14930      document.body.appendChild(m);
14931      var g=document.getElementById('scheme-grid');
14932      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);});
14933      var cl=document.getElementById('settings-close');
14934      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);
14935      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');});
14936      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14937      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14938    }
14939    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14940  }());
14941  </script>
14942  <footer class="site-footer">
14943    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
14944    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14945    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14946    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14947    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14948  </footer>
14949  {% if confluence_configured %}
14950  <script nonce="{{ csp_nonce }}">
14951  (function() {
14952    var postBtn = document.getElementById('postConfluenceBtn');
14953    var copyBtn = document.getElementById('copyWikiBtn');
14954    var modal   = document.getElementById('confluenceModal');
14955    if (!postBtn || !modal) return;
14956
14957    postBtn.addEventListener('click', function() {
14958      document.getElementById('confStatus').style.display = 'none';
14959      modal.style.display = 'flex';
14960    });
14961    document.getElementById('confCancelBtn').addEventListener('click', function() {
14962      modal.style.display = 'none';
14963    });
14964    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
14965
14966    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
14967      var btn = this;
14968      btn.disabled = true;
14969      var status = document.getElementById('confStatus');
14970      status.style.display = 'block';
14971      status.style.background = '#dbeafe';
14972      status.style.color = '#1e40af';
14973      status.textContent = 'Posting to Confluence…';
14974      var resp = await fetch('/api/confluence/post', {
14975        method: 'POST',
14976        headers: { 'Content-Type': 'application/json' },
14977        body: JSON.stringify({
14978          run_id: '{{ run_id }}',
14979          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
14980          report_url: document.getElementById('confReportUrl').value.trim() || null
14981        })
14982      });
14983      var data = await resp.json();
14984      if (data.ok) {
14985        status.style.background = '#dcfce7'; status.style.color = '#166534';
14986        status.textContent = 'Posted! Page ID: ' + data.page_id;
14987      } else {
14988        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
14989        status.textContent = 'Error: ' + (data.error || 'Unknown error');
14990      }
14991      btn.disabled = false;
14992    });
14993
14994    if (copyBtn) {
14995      copyBtn.addEventListener('click', async function() {
14996        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
14997        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
14998        var text = await resp.text();
14999        try {
15000          await navigator.clipboard.writeText(text);
15001          var orig = copyBtn.textContent;
15002          copyBtn.textContent = 'Copied!';
15003          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
15004        } catch(e) {
15005          alert('Clipboard write failed — check browser permissions.');
15006        }
15007      });
15008    }
15009  })();
15010  </script>
15011  {% endif %}
15012  {% if let Some(banner) = report_header_footer %}
15013  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
15014  {% endif %}
15015</body>
15016</html>
15017"##,
15018    ext = "html"
15019)]
15020// Template structs need many bool fields to pass Askama rendering flags.
15021#[allow(clippy::struct_excessive_bools)]
15022struct ResultTemplate {
15023    version: &'static str,
15024    report_title: String,
15025    project_path: String,
15026    output_dir: String,
15027    run_id: String,
15028    files_analyzed: u64,
15029    files_skipped: u64,
15030    physical_lines: u64,
15031    code_lines: u64,
15032    comment_lines: u64,
15033    blank_lines: u64,
15034    mixed_lines: u64,
15035    functions: u64,
15036    classes: u64,
15037    variables: u64,
15038    imports: u64,
15039    html_url: Option<String>,
15040    pdf_url: Option<String>,
15041    json_url: Option<String>,
15042    html_download_url: Option<String>,
15043    pdf_download_url: Option<String>,
15044    json_download_url: Option<String>,
15045    html_path: Option<String>,
15046    pdf_path: Option<String>,
15047    json_path: Option<String>,
15048    prev_run_id: Option<String>,
15049    prev_run_timestamp: Option<String>,
15050    prev_run_code_lines: Option<u64>,
15051    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
15052    prev_fa_str: String,
15053    prev_fs_str: String,
15054    prev_pl_str: String,
15055    prev_cl_str: String,
15056    prev_cml_str: String,
15057    prev_bl_str: String,
15058    // Signed change column for main metrics
15059    delta_fa_str: String,
15060    delta_fa_class: String,
15061    delta_fs_str: String,
15062    delta_fs_class: String,
15063    delta_pl_str: String,
15064    delta_pl_class: String,
15065    delta_cl_str: String,
15066    delta_cl_class: String,
15067    delta_cml_str: String,
15068    delta_cml_class: String,
15069    delta_bl_str: String,
15070    delta_bl_class: String,
15071    // delta vs previous scan
15072    delta_lines_added: Option<i64>,
15073    delta_lines_removed: Option<i64>,
15074    delta_lines_net_str: String,
15075    delta_lines_net_class: String,
15076    delta_files_added: Option<usize>,
15077    delta_files_removed: Option<usize>,
15078    delta_files_modified: Option<usize>,
15079    delta_files_unchanged: Option<usize>,
15080    delta_unmodified_lines: Option<u64>,
15081    // git context
15082    git_branch: Option<String>,
15083    git_commit: Option<String>,
15084    git_author: Option<String>,
15085    // history
15086    prev_scan_count: usize,
15087    current_scan_number: usize,
15088    // submodule breakdown (empty when not requested)
15089    submodule_rows: Vec<SubmoduleRow>,
15090    scan_config_url: String,
15091    lang_chart_json: String,
15092    // Askama reads these via proc-macro expansion; clippy can't trace through it.
15093    #[allow(dead_code)]
15094    scatter_chart_json: String,
15095    #[allow(dead_code)]
15096    semantic_chart_json: String,
15097    #[allow(dead_code)]
15098    submodule_chart_json: String,
15099    #[allow(dead_code)]
15100    has_submodule_data: bool,
15101    #[allow(dead_code)]
15102    has_semantic_data: bool,
15103    pdf_generating: bool,
15104    csp_nonce: String,
15105    /// Whether Confluence integration is configured — shows Post button when true.
15106    confluence_configured: bool,
15107    /// Header/footer identification banner, mirrored from the HTML/PDF report.
15108    report_header_footer: Option<String>,
15109}
15110
15111#[derive(Template)]
15112#[template(
15113    source = r##"
15114<!doctype html>
15115<html lang="en">
15116<head>
15117  <meta charset="utf-8">
15118  <meta name="viewport" content="width=device-width, initial-scale=1">
15119  <title>OxideSLOC | Analyzing…</title>
15120  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15121  <style nonce="{{ csp_nonce }}">
15122    :root {
15123      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15124      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15125      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15126      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15127    }
15128    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15129    *{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);}
15130    .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);}
15131    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15132    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
15133    .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));}
15134    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15135    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
15136    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
15137    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15138    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15139    @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; } }
15140    .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;}
15141    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15142    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15143    .page-body{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
15144    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
15145    .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;}
15146    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
15147    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
15148    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
15149    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
15150    .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;}
15151    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
15152    .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;}
15153    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
15154    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
15155    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
15156    .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;}
15157    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
15158    .hidden{display:none!important;}
15159    .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;}
15160    .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;}
15161    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
15162    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
15163    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
15164    .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);}
15165    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
15166    .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;}
15167    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
15168    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15169    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15170    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
15171    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15172    .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;}
15173    @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));}}
15174    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15175    .site-footer a{color:var(--muted);}
15176    .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;}
15177    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
15178    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
15179    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
15180  </style>
15181</head>
15182<body>
15183  <div class="background-watermarks" aria-hidden="true">
15184    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15185    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15186    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15187    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15188    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15189    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15190  </div>
15191  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15192  <nav class="top-nav">
15193    <div class="top-nav-inner">
15194      <a href="/" class="brand">
15195        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
15196        <div class="brand-copy">
15197          <h1 class="brand-title">OxideSLOC</h1>
15198          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15199        </div>
15200      </a>
15201      <div class="nav-right">
15202        <a class="nav-pill" href="/">Home</a>
15203        <div class="nav-dropdown">
15204          <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>
15205          <div class="nav-dropdown-menu">
15206            <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>
15207          </div>
15208        </div>
15209        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15210        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15211        <div class="nav-dropdown">
15212          <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>
15213          <div class="nav-dropdown-menu">
15214            <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>
15215          </div>
15216        </div>
15217        <div class="server-status-wrap">
15218          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15219          <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>
15220        </div>
15221        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15222          <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>
15223        </button>
15224        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15225          <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>
15226          <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>
15227        </button>
15228      </div>
15229    </div>
15230  </nav>
15231  <div class="page-body">
15232    <div class="wait-panel">
15233      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
15234      <h2 class="wait-title">Analyzing your project…</h2>
15235      <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
15236      <div class="path-block">{{ project_path }}</div>
15237      <div class="metrics-row">
15238        <div class="metric-card">
15239          <div class="metric-label">Elapsed</div>
15240          <div class="metric-value" id="elapsed">0s</div>
15241        </div>
15242        <div class="metric-card">
15243          <div class="metric-label">Phase</div>
15244          <div class="metric-value" id="phase">Starting</div>
15245        </div>
15246      </div>
15247      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
15248      <div class="warn-slow hidden" id="warn-slow">
15249        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.
15250      </div>
15251      <div class="err-panel hidden" id="err-panel">
15252        <strong>Analysis failed</strong>
15253        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
15254      </div>
15255      <div class="actions hidden" id="actions">
15256        <a href="/scan" class="btn-primary">Try Again</a>
15257        <a href="/view-reports" class="btn-outline">View Reports</a>
15258      </div>
15259    </div>
15260  </div>
15261  <script nonce="{{ csp_nonce }}">
15262    (function() {
15263      var WAIT_ID = {{ wait_id_json|safe }};
15264      var startTime = Date.now();
15265      var pollInterval = 1500;
15266      var retries = 0;
15267      var maxRetries = 5;
15268      var warnShown = false;
15269
15270      function elapsed() {
15271        return Math.floor((Date.now() - startTime) / 1000);
15272      }
15273
15274      function updateElapsed() {
15275        var s = elapsed();
15276        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
15277      }
15278
15279      function setPhase(txt) {
15280        document.getElementById('phase').textContent = txt;
15281      }
15282
15283      var elapsedTimer = setInterval(updateElapsed, 1000);
15284
15285      function poll() {
15286        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
15287          .then(function(r) {
15288            if (!r.ok) throw new Error('HTTP ' + r.status);
15289            return r.json();
15290          })
15291          .then(function(data) {
15292            retries = 0;
15293            if (data.state === 'complete') {
15294              clearInterval(elapsedTimer);
15295              setPhase('Done');
15296              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
15297            } else if (data.state === 'failed') {
15298              clearInterval(elapsedTimer);
15299              setPhase('Failed');
15300              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
15301              document.getElementById('err-panel').classList.remove('hidden');
15302              document.getElementById('actions').classList.remove('hidden');
15303            } else {
15304              // still running
15305              var s = elapsed();
15306              if (s > 90 && !warnShown) {
15307                warnShown = true;
15308                document.getElementById('warn-slow').classList.remove('hidden');
15309              }
15310              setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
15311              setTimeout(poll, pollInterval);
15312            }
15313          })
15314          .catch(function(err) {
15315            retries++;
15316            if (retries >= maxRetries) {
15317              clearInterval(elapsedTimer);
15318              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
15319              document.getElementById('err-panel').classList.remove('hidden');
15320              document.getElementById('actions').classList.remove('hidden');
15321            } else {
15322              // exponential back-off capped at 8s
15323              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
15324            }
15325          });
15326      }
15327
15328      setTimeout(poll, pollInterval);
15329    })();
15330  </script>
15331  <footer class="site-footer">
15332    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
15333    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15334    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15335    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15336    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
15337  </footer>
15338  <script nonce="{{ csp_nonce }}">
15339    (function(){
15340      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
15341      if(s==="dark")b.classList.add("dark-theme");
15342      var tt=document.getElementById("theme-toggle");
15343      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
15344    })();
15345    (function spawnCodeParticles(){
15346      var c=document.getElementById('code-particles');if(!c)return;
15347      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'];
15348      for(var i=0;i<32;i++){(function(idx){
15349        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
15350        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
15351        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
15352        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
15353        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
15354        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
15355        c.appendChild(el);
15356      })(i);}
15357    })();
15358    (function randomizeWatermarks(){
15359      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15360      var placed=[];
15361      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;}
15362      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];}
15363      var half=Math.floor(wms.length/2);
15364      wms.forEach(function(img,i){
15365        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
15366        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
15367        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
15368        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
15369        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
15370        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
15371      });
15372    })();
15373  </script>
15374  <script nonce="{{ csp_nonce }}">
15375  (function(){
15376    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'}];
15377    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);});}
15378    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15379    function init(){
15380      var btn=document.getElementById('settings-btn');if(!btn)return;
15381      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15382      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>';
15383      document.body.appendChild(m);
15384      var g=document.getElementById('scheme-grid');
15385      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);});
15386      var cl=document.getElementById('settings-close');
15387      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);
15388      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');});
15389      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15390      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15391    }
15392    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15393  }());
15394  </script>
15395</body>
15396</html>
15397"##,
15398    ext = "html"
15399)]
15400struct ScanWaitTemplate {
15401    version: &'static str,
15402    wait_id_json: String,
15403    project_path: String,
15404    csp_nonce: String,
15405}
15406
15407#[derive(Template)]
15408#[template(
15409    source = r##"
15410<!doctype html>
15411<html lang="en">
15412<head>
15413  <meta charset="utf-8">
15414  <meta name="viewport" content="width=device-width, initial-scale=1">
15415  <title>OxideSLOC | Error</title>
15416  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15417  <style nonce="{{ csp_nonce }}">
15418    :root {
15419      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15420      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15421      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15422      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15423    }
15424    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15425    *{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);}
15426    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15427    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15428    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15429    .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);}
15430    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15431    .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));}
15432    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15433    .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;}
15434    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15435    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15436    @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; } }
15437    .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;}
15438    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15439    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15440    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15441    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15442    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15443    .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;}
15444    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15445    .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);}
15446    .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;}
15447    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15448    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15449    .settings-modal-body{padding:14px 16px 16px;}
15450    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15451    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15452    .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;}
15453    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15454    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15455    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15456    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15457    .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;}
15458    .tz-select:focus{border-color:var(--oxide);}
15459    .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15460    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15461    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15462    .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;}
15463    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15464    .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);}
15465    .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;}
15466    .btn-secondary:hover{background:var(--line);}
15467    .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;}
15468    .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;}
15469    .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;}
15470    @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));}}
15471    .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;}
15472  </style>
15473</head>
15474<body>
15475  <div class="background-watermarks" aria-hidden="true">
15476    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15477    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15478    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15479    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15480    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15481    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15482  </div>
15483  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15484  <div class="top-nav">
15485    <div class="top-nav-inner">
15486      <a class="brand" href="/">
15487        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15488        <div class="brand-copy">
15489          <div class="brand-title">OxideSLOC</div>
15490          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15491        </div>
15492      </a>
15493      <div class="nav-right">
15494        <a class="nav-pill" href="/">Home</a>
15495        <div class="nav-dropdown">
15496          <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>
15497          <div class="nav-dropdown-menu">
15498            <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>
15499          </div>
15500        </div>
15501        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15502        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15503        <div class="nav-dropdown">
15504          <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>
15505          <div class="nav-dropdown-menu">
15506            <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>
15507          </div>
15508        </div>
15509        <div class="server-status-wrap">
15510          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15511          <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>
15512        </div>
15513        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15514          <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>
15515        </button>
15516        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15517          <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>
15518          <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>
15519        </button>
15520      </div>
15521    </div>
15522  </div>
15523
15524  <div class="page">
15525    <div class="panel">
15526      <h1>Error</h1>
15527      <div class="error-box">{{ message }}</div>
15528      <div class="actions">
15529        <a class="btn-primary" href="/scan">Back to setup</a>
15530        {% if let Some(report_url) = last_report_url %}
15531        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
15532        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
15533        {% else %}
15534        <a class="btn-secondary" href="/view-reports">View Reports</a>
15535        {% endif %}
15536      </div>
15537    </div>
15538  </div>
15539  <script nonce="{{ csp_nonce }}">
15540    (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");});})();
15541    (function spawnCodeParticles() {
15542      var container = document.getElementById('code-particles');
15543      if (!container) return;
15544      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'];
15545      for (var i = 0; i < 38; i++) {
15546        (function(idx) {
15547          var el = document.createElement('span');
15548          el.className = 'code-particle';
15549          el.textContent = snippets[idx % snippets.length];
15550          var left = Math.random() * 94 + 2;
15551          var top = Math.random() * 88 + 6;
15552          var dur = (Math.random() * 10 + 9).toFixed(1);
15553          var delay = (Math.random() * 18).toFixed(1);
15554          var rot = (Math.random() * 26 - 13).toFixed(1);
15555          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15556          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';
15557          container.appendChild(el);
15558        })(i);
15559      }
15560    })();
15561    (function randomizeWatermarks() {
15562      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15563      var placed = [];
15564      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; }
15565      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]; }
15566      var half = Math.floor(wms.length/2);
15567      wms.forEach(function(img, i) {
15568        var pos = pick(i < half);
15569        var w = Math.floor(Math.random()*60+80);
15570        var rot = (Math.random()*40-20).toFixed(1);
15571        var op = (Math.random()*0.08+0.05).toFixed(2);
15572        var animDur = (Math.random()*6+5).toFixed(1);
15573        var animDelay = (Math.random()*10).toFixed(1);
15574        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';
15575      });
15576    })();
15577  </script>
15578  <script nonce="{{ csp_nonce }}">
15579  (function(){
15580    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'}];
15581    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);});}
15582    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15583    function init(){
15584      var btn=document.getElementById('settings-btn');if(!btn)return;
15585      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15586      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>';
15587      document.body.appendChild(m);
15588      var g=document.getElementById('scheme-grid');
15589      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);});
15590      var cl=document.getElementById('settings-close');
15591      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);
15592      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');});
15593      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15594      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15595    }
15596    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15597  }());
15598  </script>
15599</body>
15600</html>
15601"##,
15602    ext = "html"
15603)]
15604struct ErrorTemplate {
15605    message: String,
15606    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
15607    last_report_url: Option<String>,
15608    /// Label for the secondary action button; defaults to "View last report" when None.
15609    last_report_label: Option<String>,
15610    csp_nonce: String,
15611}
15612
15613// ── RelocateScanTemplate ──────────────────────────────────────────────────────
15614
15615#[derive(Template)]
15616#[template(
15617    source = r##"
15618<!doctype html>
15619<html lang="en">
15620<head>
15621  <meta charset="utf-8">
15622  <meta name="viewport" content="width=device-width, initial-scale=1">
15623  <title>OxideSLOC | Locate Scan Files</title>
15624  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15625  <style nonce="{{ csp_nonce }}">
15626    :root {
15627      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15628      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15629      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15630      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15631    }
15632    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15633    *{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);}
15634    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15635    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15636    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15637    .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);}
15638    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15639    .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));}
15640    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15641    .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;}
15642    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15643    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
15644    @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;}}
15645    .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;}
15646    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15647    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15648    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15649    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15650    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15651    .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;}
15652    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15653    .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);}
15654    .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;}
15655    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15656    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15657    .settings-modal-body{padding:14px 16px 16px;}
15658    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15659    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15660    .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;}
15661    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15662    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15663    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15664    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15665    .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;}
15666    .tz-select:focus{border-color:var(--oxide);}
15667    .page{max-width:860px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15668    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15669    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15670    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
15671    .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;}
15672    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15673    .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;}
15674    .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;}
15675    .btn-secondary:hover{background:var(--line);}
15676    .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;}
15677    .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;}
15678    .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;}
15679    @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));}}
15680    .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;}
15681    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
15682    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
15683    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
15684    .relocate-row{display:flex;gap:8px;align-items:stretch;}
15685    .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;}
15686    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
15687    body.dark-theme .relocate-input{background:var(--surface-2);}
15688  </style>
15689</head>
15690<body>
15691  <div class="background-watermarks" aria-hidden="true">
15692    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15693    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15694    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15695    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15696    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15697    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15698  </div>
15699  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15700  <div class="top-nav">
15701    <div class="top-nav-inner">
15702      <a class="brand" href="/">
15703        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15704        <div class="brand-copy">
15705          <div class="brand-title">OxideSLOC</div>
15706          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15707        </div>
15708      </a>
15709      <div class="nav-right">
15710        <a class="nav-pill" href="/">Home</a>
15711        <div class="nav-dropdown">
15712          <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>
15713          <div class="nav-dropdown-menu">
15714            <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>
15715          </div>
15716        </div>
15717        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
15718        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15719        <div class="nav-dropdown">
15720          <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>
15721          <div class="nav-dropdown-menu">
15722            <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>
15723          </div>
15724        </div>
15725        <div class="server-status-wrap">
15726          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15727          <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>
15728        </div>
15729        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15730          <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>
15731        </button>
15732        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15733          <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>
15734          <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>
15735        </button>
15736      </div>
15737    </div>
15738  </div>
15739
15740  <div class="page">
15741    <div class="panel">
15742      <h1>Scan Files Moved</h1>
15743      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
15744      <div class="error-box">{{ message }}</div>
15745      <div class="relocate-section">
15746        <h2>Locate Scan Output</h2>
15747        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
15748        <form method="post" action="/relocate-scan">
15749          <input type="hidden" name="run_id" value="{{ run_id }}">
15750          <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
15751          <div class="relocate-row">
15752            <input type="text" id="relocate-folder" name="folder_path"
15753                   value="{{ folder_hint }}"
15754                   placeholder="Path to folder containing scan output..."
15755                   class="relocate-input" autocomplete="off" spellcheck="false">
15756            {% if !server_mode %}
15757            <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
15758            {% endif %}
15759          </div>
15760          <div style="margin-top:12px;">
15761            <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
15762          </div>
15763        </form>
15764      </div>
15765      <div class="actions">
15766        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
15767        <a class="btn-secondary" href="/view-reports">View Reports</a>
15768      </div>
15769    </div>
15770  </div>
15771  <script nonce="{{ csp_nonce }}">
15772    (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");});})();
15773    (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);}})();
15774    (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';});})();
15775  </script>
15776  <script nonce="{{ csp_nonce }}">
15777  (function(){
15778    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'}];
15779    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);});}
15780    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15781    function init(){
15782      var btn=document.getElementById('settings-btn');if(!btn)return;
15783      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15784      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>';
15785      document.body.appendChild(m);
15786      var g=document.getElementById('scheme-grid');
15787      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);});
15788      var cl=document.getElementById('settings-close');
15789      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);
15790      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');});
15791      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15792      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15793    }
15794    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15795  }());
15796  (function(){
15797    var btn=document.getElementById('browse-relocate-btn');
15798    if(!btn)return;
15799    btn.addEventListener('click',function(){
15800      btn.disabled=true;btn.textContent='...';
15801      var inp=document.getElementById('relocate-folder');
15802      var hint=inp?inp.value:'';
15803      fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
15804        .then(function(r){return r.json();})
15805        .then(function(d){
15806          btn.disabled=false;btn.textContent='Browse…';
15807          if(d&&d.selected_path&&inp)inp.value=d.selected_path;
15808        })
15809        .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
15810    });
15811  }());
15812  </script>
15813</body>
15814</html>
15815"##,
15816    ext = "html"
15817)]
15818struct RelocateScanTemplate {
15819    message: String,
15820    run_id: String,
15821    folder_hint: String,
15822    redirect_url: String,
15823    server_mode: bool,
15824    csp_nonce: String,
15825}
15826
15827// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
15828
15829#[derive(Template)]
15830#[template(
15831    source = r##"
15832<!doctype html>
15833<html lang="en">
15834<head>
15835  <meta charset="utf-8">
15836  <meta name="viewport" content="width=device-width, initial-scale=1">
15837  <title>OxideSLOC | View Reports</title>
15838  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15839  <style nonce="{{ csp_nonce }}">
15840    :root {
15841      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
15842      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15843      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15844      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15845      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
15846    }
15847    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; }
15848    *{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);}
15849    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15850    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15851    .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);}
15852    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15853    .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));}
15854    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15855    .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;}
15856    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15857    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15858    @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; } }
15859    .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;}
15860    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15861    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15862    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15863    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15864    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15865    .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;}
15866    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15867    .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);}
15868    .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;}
15869    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15870    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15871    .settings-modal-body{padding:14px 16px 16px;}
15872    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15873    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15874    .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;}
15875    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15876    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15877    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15878    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15879    .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;}
15880    .tz-select:focus{border-color:var(--oxide);}
15881    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
15882    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
15883    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
15884    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
15885    .panel-meta{font-size:13px;color:var(--muted);}
15886    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
15887    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
15888    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
15889    .per-page-label{font-size:13px;color:var(--muted);}
15890    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;}
15891    .filter-input{min-width:180px;cursor:text;}
15892    .table-wrap{width:100%;overflow-x:auto;}
15893    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
15894    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;}
15895    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
15896    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
15897    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
15898    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
15899    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
15900    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15901    tr:last-child td{border-bottom:none;}
15902    tr:hover td{background:var(--surface-2);}
15903    .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);}
15904    .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);}
15905    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
15906    .metric-num{font-weight:700;color:var(--text);}
15907    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
15908    .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;}
15909    .btn:hover{background:var(--line);}
15910    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15911    .btn.primary:hover{opacity:.9;}
15912    .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;}
15913    .btn-back:hover{background:var(--line);}
15914    .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;}
15915    .export-btn:hover{background:var(--line);}
15916    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
15917    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
15918    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
15919    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
15920    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
15921    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
15922    .pagination-info{font-size:13px;color:var(--muted);}
15923    .pagination-btns{display:flex;gap:6px;}
15924    .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;}
15925    .pg-btn:hover:not(:disabled){background:var(--line);}
15926    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15927    .pg-btn:disabled{opacity:.35;cursor:default;}
15928    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
15929    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15930    .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;}
15931    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
15932    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
15933    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
15934    .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);}
15935    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
15936    .stat-chip:hover .stat-chip-tip{opacity:1;}
15937    .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;}
15938    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15939    .site-footer a{color:var(--muted);}
15940    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
15941    .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%;}
15942    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
15943    .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;}
15944    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15945    .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;}
15946    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15947    .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;}
15948    .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;}
15949    .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;}
15950    @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));}}
15951    .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;}
15952    .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;}
15953    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
15954    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
15955    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
15956    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
15957    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
15958    .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;}
15959    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15960    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
15961    .watched-chip-rm:hover{color:var(--oxide);}
15962    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
15963    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
15964    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
15965    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
15966    .rpt-btn{min-width:58px;justify-content:center;}
15967    .flex-row{display:flex;align-items:center;gap:8px;}
15968    .report-cell{overflow:visible;white-space:normal;}
15969    #history-table col:nth-child(1){width:185px;}
15970    #history-table col:nth-child(2){width:220px;}
15971    #history-table col:nth-child(3){width:100px;}
15972    #history-table col:nth-child(4){width:72px;}
15973    #history-table col:nth-child(5){width:82px;}
15974    #history-table col:nth-child(6){width:82px;}
15975    #history-table col:nth-child(7){width:65px;}
15976    #history-table col:nth-child(8){width:90px;}
15977    #history-table col:nth-child(9){width:85px;}
15978    #history-table col:nth-child(10){width:115px;}
15979    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
15980    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
15981    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
15982    .submod-details summary::-webkit-details-marker{display:none;}
15983.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
15984    .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;}
15985    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
15986    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
15987  </style>
15988</head>
15989<body>
15990  <div class="background-watermarks" aria-hidden="true">
15991    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15992    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15993    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15994    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15995    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15996    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15997  </div>
15998  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15999  <div class="top-nav">
16000    <div class="top-nav-inner">
16001      <a class="brand" href="/">
16002        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16003        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
16004      </a>
16005      <div class="nav-right">
16006        <a class="nav-pill" href="/">Home</a>
16007        <div class="nav-dropdown">
16008          <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>
16009          <div class="nav-dropdown-menu">
16010            <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>
16011          </div>
16012        </div>
16013        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16014        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16015        <div class="nav-dropdown">
16016          <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>
16017          <div class="nav-dropdown-menu">
16018            <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>
16019          </div>
16020        </div>
16021        <div class="server-status-wrap">
16022          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
16023          <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>
16024        </div>
16025        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16026          <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>
16027        </button>
16028        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16029          <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>
16030          <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>
16031        </button>
16032      </div>
16033    </div>
16034  </div>
16035
16036  <div class="page">
16037    {% if let Some(err) = browse_error %}
16038    <div class="toast-error">
16039      <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>
16040      {{ err }}
16041    </div>
16042    {% endif %}
16043    {% if linked_count > 0 %}
16044    <div class="toast-success">
16045      <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>
16046      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
16047    </div>
16048    {% endif %}
16049    <div class="watched-bar">
16050      <div class="watched-bar-left">
16051        <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>
16052        <span class="watched-label">Watched Folders</span>
16053        <div class="watched-chips">
16054          {% for dir in watched_dirs %}
16055          <span class="watched-chip">
16056            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
16057            <form method="POST" action="/watched-dirs/remove" style="display:contents">
16058              <input type="hidden" name="folder_path" value="{{ dir }}">
16059              <input type="hidden" name="redirect_to" value="/view-reports">
16060              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
16061            </form>
16062          </span>
16063          {% endfor %}
16064          {% if watched_dirs.is_empty() %}
16065          <span class="watched-none">No folders watched — click Choose to add one</span>
16066          {% endif %}
16067        </div>
16068      </div>
16069      <div class="watched-bar-right">
16070        <button type="button" class="btn" id="add-watched-btn">
16071          <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>
16072          Choose
16073        </button>
16074        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
16075          <input type="hidden" name="redirect_to" value="/view-reports">
16076          <button type="submit" class="btn">&#8635; Refresh</button>
16077        </form>
16078      </div>
16079    </div>
16080    {% if total_scans > 0 %}
16081    <div class="summary-strip">
16082      <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>
16083      <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>
16084      <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>
16085      <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>
16086    </div>
16087    {% endif %}
16088
16089    <section class="panel">
16090      <div class="panel-header">
16091        <div>
16092          <h1>View Reports</h1>
16093          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
16094        </div>
16095        <div class="flex-row">
16096          <button type="button" class="export-btn" id="export-csv-btn">
16097            <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>
16098            Export CSV
16099          </button>
16100          <button type="button" class="export-btn" id="export-xls-btn">
16101            <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>
16102            Export Excel
16103          </button>
16104        </div>
16105      </div>
16106
16107      {% if entries.is_empty() %}
16108      <div class="empty-state">
16109        <strong>No reports with viewable HTML yet</strong>
16110        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.
16111      </div>
16112      {% else %}
16113      <div class="filter-row">
16114        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
16115        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
16116        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
16117      </div>
16118      <div class="table-wrap">
16119        <table id="history-table">
16120          <colgroup>
16121            <col><col><col><col><col><col><col><col><col><col>
16122          </colgroup>
16123          <thead>
16124            <tr id="history-thead">
16125              <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>
16126              <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>
16127              <th>Run ID<div class="col-resize-handle"></div></th>
16128              <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>
16129              <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>
16130              <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>
16131              <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>
16132              <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>
16133              <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>
16134              <th>Report<div class="col-resize-handle"></div></th>
16135            </tr>
16136          </thead>
16137          <tbody id="history-tbody">
16138            {% for entry in entries %}
16139            <tr class="history-row" data-run="{{ entry.run_id }}"
16140                data-timestamp="{{ entry.timestamp }}"
16141                data-project="{{ entry.project_label }}"
16142                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
16143                data-skipped="{{ entry.files_skipped }}"
16144                data-comments="{{ entry.comment_lines }}"
16145                data-blank="{{ entry.blank_lines }}"
16146                data-branch="{{ entry.git_branch }}"
16147                data-commit="{{ entry.git_commit }}"
16148                data-html-url="/runs/html/{{ entry.run_id }}">
16149              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
16150              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
16151              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
16152              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
16153              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
16154              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
16155              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
16156              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
16157              <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>
16158              <td class="report-cell">
16159                <div class="actions-cell">
16160                  {% 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 %}
16161                  {% 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 %}
16162                </div>
16163                {% if !entry.submodule_links.is_empty() %}
16164                <details class="submod-details">
16165                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
16166                  <div class="submod-link-list">
16167                    {% for sub in entry.submodule_links %}
16168                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
16169                    {% endfor %}
16170                  </div>
16171                </details>
16172                {% endif %}
16173              </td>
16174            </tr>
16175            {% endfor %}
16176          </tbody>
16177        </table>
16178      </div>
16179      <div class="pagination">
16180        <span class="pagination-info" id="pagination-info"></span>
16181        <div class="pagination-btns" id="pagination-btns"></div>
16182        <div class="flex-row">
16183          <span class="per-page-label">Show</span>
16184          <select class="per-page" id="per-page-sel">
16185            <option value="10">10 per page</option>
16186            <option value="25" selected>25 per page</option>
16187            <option value="50">50 per page</option>
16188            <option value="100">100 per page</option>
16189          </select>
16190          <span class="per-page-label" id="page-range-label"></span>
16191        </div>
16192      </div>
16193      {% endif %}
16194    </section>
16195  </div>
16196
16197  <footer class="site-footer">
16198    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
16199    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16200    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16201    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16202    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16203  </footer>
16204
16205  <script nonce="{{ csp_nonce }}">
16206    (function () {
16207      // ── Theme ──────────────────────────────────────────────────────────────
16208      var storageKey = 'oxide-sloc-theme';
16209      var body = document.body;
16210      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16211      var toggle = document.getElementById('theme-toggle');
16212      if (toggle) toggle.addEventListener('click', function () {
16213        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16214        body.classList.toggle('dark-theme', next === 'dark');
16215        try { localStorage.setItem(storageKey, next); } catch(e) {}
16216      });
16217
16218      // ── State ─────────────────────────────────────────────────────────────
16219      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
16220      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
16221      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16222
16223      // Aggregate stats from first (most recent) row
16224      if (allRows.length) {
16225        var first = allRows[0];
16226        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();}
16227        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>':'');}
16228        setChipVal('agg-code', first.dataset.code);
16229        setChipVal('agg-files', first.dataset.files);
16230        setChipVal('agg-skipped', first.dataset.skipped);
16231      }
16232
16233      // ── Branch filter population ──────────────────────────────────────────
16234      (function() {
16235        var branches = {};
16236        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16237        var sel = document.getElementById('branch-filter');
16238        if (sel) Object.keys(branches).sort().forEach(function(b) {
16239          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16240        });
16241      })();
16242
16243      // ── Filter ────────────────────────────────────────────────────────────
16244      function getFilteredRows() {
16245        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16246        var branch = ((document.getElementById('branch-filter') || {}).value || '');
16247        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
16248          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16249          if (branch && (r.dataset.branch || '') !== branch) return false;
16250          return true;
16251        });
16252      }
16253
16254      // ── Pagination ────────────────────────────────────────────────────────
16255      function renderPage() {
16256        var filtered = getFilteredRows();
16257        var total = filtered.length;
16258        var totalPages = Math.max(1, Math.ceil(total / perPage));
16259        currentPage = Math.min(currentPage, totalPages);
16260        var start = (currentPage - 1) * perPage;
16261        var end = Math.min(start + perPage, total);
16262        var shown = {};
16263        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16264        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
16265          r.style.display = shown[r.dataset.run] ? '' : 'none';
16266        });
16267        var rl = document.getElementById('page-range-label');
16268        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16269        var info = document.getElementById('pagination-info');
16270        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16271        var btns = document.getElementById('pagination-btns');
16272        if (!btns) return;
16273        btns.innerHTML = '';
16274        function makeBtn(lbl, pg, active, disabled) {
16275          var b = document.createElement('button');
16276          b.className = 'pg-btn' + (active ? ' active' : '');
16277          b.textContent = lbl; b.disabled = disabled;
16278          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16279          return b;
16280        }
16281        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16282        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16283        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16284        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16285      }
16286
16287      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16288      window.applyFilters = function() { currentPage = 1; renderPage(); };
16289
16290      // ── Sorting ───────────────────────────────────────────────────────────
16291      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
16292      function doSort(col, type, order) {
16293        var tbody = document.getElementById('history-tbody');
16294        if (!tbody) return;
16295        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16296        rows.sort(function(a, b) {
16297          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16298          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16299          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16300          return va < vb ? 1 : va > vb ? -1 : 0;
16301        });
16302        rows.forEach(function(r) { tbody.appendChild(r); });
16303        currentPage = 1; renderPage();
16304      }
16305      sortHeaders.forEach(function(th) {
16306        th.addEventListener('click', function(e) {
16307          if (e.target.classList.contains('col-resize-handle')) return;
16308          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16309          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16310          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16311          th.classList.add('sort-' + sortOrder);
16312          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16313          doSort(col, type, sortOrder);
16314        });
16315      });
16316
16317      // ── Column resize ─────────────────────────────────────────────────────
16318      (function() {
16319        var table = document.getElementById('history-table');
16320        if (!table) return;
16321        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16322        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
16323        ths.forEach(function(th, i) {
16324          var handle = th.querySelector('.col-resize-handle');
16325          if (!handle || !cols[i]) return;
16326          var startX, startW;
16327          handle.addEventListener('mousedown', function(e) {
16328            e.stopPropagation(); e.preventDefault();
16329            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16330            handle.classList.add('dragging');
16331            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16332            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16333            document.addEventListener('mousemove', onMove);
16334            document.addEventListener('mouseup', onUp);
16335          });
16336        });
16337      })();
16338
16339      // ── Reset view ────────────────────────────────────────────────────────
16340      window.resetView = function() {
16341        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16342        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16343        sortCol = null; sortOrder = 'asc';
16344        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16345        var tbody = document.getElementById('history-tbody');
16346        if (tbody) {
16347          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16348          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16349          rows.forEach(function(r) { tbody.appendChild(r); });
16350        }
16351        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16352        var table = document.getElementById('history-table');
16353        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
16354        currentPage = 1; renderPage();
16355      };
16356
16357      renderPage();
16358
16359      // ── Export helpers ────────────────────────────────────────────────────
16360      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
16361      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
16362      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);}
16363      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;');}
16364      function slocXlsx(fname,sheet,hdrs,rows){
16365        var enc=new TextEncoder();
16366        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;}
16367        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;}
16368        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
16369        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
16370        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16371        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;}
16372        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];}
16373        var rx='<row r="1">';
16374        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
16375        rx+='</row>';
16376        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>';});
16377        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
16378        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>';
16379        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>';
16380        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>';
16381        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>',
16382          '_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>',
16383          '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>',
16384          '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>',
16385          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
16386        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'];
16387        var zparts=[],zcds=[],zoff=0,znf=0;
16388        order.forEach(function(name){
16389          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
16390          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]);
16391          var entry=new Uint8Array(lha.length+nb.length+sz);
16392          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
16393          zparts.push(entry);
16394          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));
16395          var cde=new Uint8Array(cda.length+nb.length);
16396          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
16397          zcds.push(cde);zoff+=entry.length;znf++;
16398        });
16399        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
16400        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]);
16401        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
16402        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
16403        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
16404        zout.set(new Uint8Array(ea),zpos);
16405        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
16406      }
16407
16408      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
16409      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;}
16410      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
16411      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
16412
16413      var csvBtn = document.getElementById('export-csv-btn');
16414      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
16415      var xlsBtn = document.getElementById('export-xls-btn');
16416      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
16417
16418      // ── Remaining CSP-safe event bindings ────────────────────────────────
16419      (function wireEvents() {
16420        var el;
16421        el = document.getElementById('reset-view-btn');
16422        if (el) el.addEventListener('click', window.resetView);
16423        el = document.getElementById('project-filter');
16424        if (el) el.addEventListener('input', window.applyFilters);
16425        el = document.getElementById('branch-filter');
16426        if (el) el.addEventListener('change', window.applyFilters);
16427        el = document.getElementById('per-page-sel');
16428        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
16429        el = document.getElementById('add-watched-btn');
16430        if (el) el.addEventListener('click', function() {
16431          fetch('/pick-directory?kind=reports')
16432            .then(function(r) { return r.json(); })
16433            .then(function(data) {
16434              if (!data.cancelled && data.selected_path) {
16435                var form = document.createElement('form');
16436                form.method = 'POST';
16437                form.action = '/watched-dirs/add';
16438                var ri = document.createElement('input');
16439                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
16440                var fi = document.createElement('input');
16441                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
16442                form.appendChild(ri); form.appendChild(fi);
16443                document.body.appendChild(form);
16444                form.submit();
16445              }
16446            })
16447            .catch(function(e) { alert('Could not open folder picker: ' + e); });
16448        });
16449      })();
16450
16451      (function randomizeWatermarks() {
16452        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16453        if (!wms.length) return;
16454        var placed = [];
16455        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;}
16456        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];}
16457        var half=Math.floor(wms.length/2);
16458        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;});
16459      })();
16460
16461      (function spawnCodeParticles() {
16462        var container = document.getElementById('code-particles');
16463        if (!container) return;
16464        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'];
16465        for (var i = 0; i < 38; i++) {
16466          (function(idx) {
16467            var el = document.createElement('span');
16468            el.className = 'code-particle';
16469            el.textContent = snippets[idx % snippets.length];
16470            var left = Math.random() * 94 + 2;
16471            var top = Math.random() * 88 + 6;
16472            var dur = (Math.random() * 10 + 9).toFixed(1);
16473            var delay = (Math.random() * 18).toFixed(1);
16474            var rot = (Math.random() * 26 - 13).toFixed(1);
16475            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16476            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';
16477            container.appendChild(el);
16478          })(i);
16479        }
16480      })();
16481    })();
16482  </script>
16483  <script nonce="{{ csp_nonce }}">
16484  (function(){
16485    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'}];
16486    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);});}
16487    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16488    function init(){
16489      var btn=document.getElementById('settings-btn');if(!btn)return;
16490      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16491      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>';
16492      document.body.appendChild(m);
16493      var g=document.getElementById('scheme-grid');
16494      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);});
16495      var cl=document.getElementById('settings-close');
16496      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);
16497      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');});
16498      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16499      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16500    }
16501    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16502  }());
16503  </script>
16504</body>
16505</html>
16506"##,
16507    ext = "html"
16508)]
16509struct HistoryTemplate {
16510    version: &'static str,
16511    entries: Vec<HistoryEntryRow>,
16512    total_scans: usize,
16513    linked_count: usize,
16514    browse_error: Option<String>,
16515    watched_dirs: Vec<String>,
16516    csp_nonce: String,
16517}
16518
16519// ── CompareSelectTemplate ──────────────────────────────────────────────────────
16520
16521#[derive(Template)]
16522#[template(
16523    source = r##"
16524<!doctype html>
16525<html lang="en">
16526<head>
16527  <meta charset="utf-8">
16528  <meta name="viewport" content="width=device-width, initial-scale=1">
16529  <title>OxideSLOC | Compare Scans</title>
16530  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16531  <style nonce="{{ csp_nonce }}">
16532    :root {
16533      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
16534      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16535      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16536      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16537      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
16538    }
16539    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
16540    *{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);}
16541    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16542    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16543    .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);}
16544    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16545    .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));}
16546    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16547    .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;}
16548    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16549    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16550    @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; } }
16551    .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;}
16552    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16553    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
16554    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16555    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16556    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16557    .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;}
16558    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16559    .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);}
16560    .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;}
16561    .settings-close:hover{color:var(--text);background:var(--surface-2);}
16562    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16563    .settings-modal-body{padding:14px 16px 16px;}
16564    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16565    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16566    .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;}
16567    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16568    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16569    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16570    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16571    .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;}
16572    .tz-select:focus{border-color:var(--oxide);}
16573    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
16574    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
16575    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
16576    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
16577    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
16578    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
16579    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
16580    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
16581    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
16582    .per-page-label{font-size:13px;color:var(--muted);}
16583    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;}
16584    .filter-input{min-width:180px;cursor:text;}
16585    .table-wrap{width:100%;overflow-x:auto;}
16586    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
16587    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;}
16588    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
16589    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
16590    #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;}
16591    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
16592    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
16593    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
16594    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
16595    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
16596    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
16597    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
16598    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
16599    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
16600    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
16601    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
16602    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
16603    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16604    tr:last-child td{border-bottom:none;}
16605    tr.selected td{background:var(--sel-bg);}
16606    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
16607    tr:hover:not(.selected) td{background:var(--surface-2);}
16608    tr{cursor:pointer;}
16609    .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);}
16610    .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);}
16611    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
16612    .metric-num{font-weight:700;color:var(--text);}
16613    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
16614    .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;}
16615    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
16616    .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;}
16617    .btn:hover{background:var(--line);}
16618    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
16619    .btn.primary:hover{opacity:.9;}
16620    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
16621    .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;}
16622    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
16623    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
16624    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
16625    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
16626    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
16627    .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;}
16628    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16629    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
16630    .watched-chip-rm:hover{color:var(--oxide);}
16631    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
16632    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
16633    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
16634    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
16635    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
16636    .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;}
16637    .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;}
16638    .btn-back:hover{background:var(--line);}
16639    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
16640    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
16641    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
16642    .pagination-info{font-size:13px;color:var(--muted);}
16643    .pagination-btns{display:flex;gap:6px;}
16644    .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;}
16645    .pg-btn:hover:not(:disabled){background:var(--line);}
16646    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
16647    .pg-btn:disabled{opacity:.35;cursor:default;}
16648    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
16649    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16650    .site-footer a{color:var(--muted);}
16651    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
16652    .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;}
16653    .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;}
16654    .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;}
16655    @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));}}
16656    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
16657    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
16658    .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;}
16659    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
16660    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
16661    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
16662    .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);}
16663    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
16664    .stat-chip:hover .stat-chip-tip{opacity:1;}
16665    .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;}
16666    .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;}
16667    .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%;}
16668    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
16669    .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;}
16670    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
16671    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
16672    .hidden{display:none!important;}
16673    .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%;}
16674    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
16675    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
16676    .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;}
16677    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
16678    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
16679    .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;}
16680    .scope-option:hover{background:var(--line);}
16681    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
16682    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
16683    .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;}
16684    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
16685    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
16686    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
16687    .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;}
16688  </style>
16689</head>
16690<body>
16691  <div class="background-watermarks" aria-hidden="true">
16692    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16693    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16694    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16695    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16696    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16697    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16698  </div>
16699  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16700  <div class="top-nav">
16701    <div class="top-nav-inner">
16702      <a class="brand" href="/">
16703        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16704        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
16705      </a>
16706      <div class="nav-right">
16707        <a class="nav-pill" href="/">Home</a>
16708        <div class="nav-dropdown">
16709          <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>
16710          <div class="nav-dropdown-menu">
16711            <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>
16712          </div>
16713        </div>
16714        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16715        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16716        <div class="nav-dropdown">
16717          <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>
16718          <div class="nav-dropdown-menu">
16719            <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>
16720          </div>
16721        </div>
16722        <div class="server-status-wrap">
16723          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
16724          <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>
16725        </div>
16726        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16727          <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>
16728        </button>
16729        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16730          <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>
16731          <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>
16732        </button>
16733      </div>
16734    </div>
16735  </div>
16736
16737  <div class="page">
16738    <div class="watched-bar">
16739      <div class="watched-bar-left">
16740        <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>
16741        <span class="watched-label">Watched Folders</span>
16742        <div class="watched-chips">
16743          {% for dir in watched_dirs %}
16744          <span class="watched-chip">
16745            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
16746            <form method="POST" action="/watched-dirs/remove" style="display:contents">
16747              <input type="hidden" name="folder_path" value="{{ dir }}">
16748              <input type="hidden" name="redirect_to" value="/compare-scans">
16749              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
16750            </form>
16751          </span>
16752          {% endfor %}
16753          {% if watched_dirs.is_empty() %}
16754          <span class="watched-none">No folders watched — click Choose to add one</span>
16755          {% endif %}
16756        </div>
16757      </div>
16758      <div class="watched-bar-right">
16759        <button type="button" class="btn" id="add-watched-btn">
16760          <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>
16761          Choose
16762        </button>
16763        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
16764          <input type="hidden" name="redirect_to" value="/compare-scans">
16765          <button type="submit" class="btn">&#8635; Refresh</button>
16766        </form>
16767      </div>
16768    </div>
16769    {% if total_scans > 0 %}
16770    <div class="summary-strip">
16771      <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>
16772      <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>
16773      <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>
16774      <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>
16775    </div>
16776    {% endif %}
16777    <section class="panel">
16778      <div class="panel-header">
16779        <div>
16780          <h1>Compare Scans</h1>
16781          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
16782        </div>
16783        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
16784          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
16785            <button class="btn primary" id="compare-btn" disabled>
16786              <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>
16787              Compare <span class="sel-count" id="sel-count">0/2</span>
16788            </button>
16789          </div>
16790        </div>
16791      </div>
16792
16793      {% if entries.is_empty() %}
16794      <div class="empty-state">
16795        <strong>No scans yet</strong>
16796        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.
16797      </div>
16798      {% else %}
16799      <div class="filter-row">
16800        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
16801        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
16802        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
16803      </div>
16804      <div class="scope-panel hidden" id="scope-panel">
16805        <div class="scope-panel-label">
16806          <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>
16807          Compare scope — choose what to include
16808        </div>
16809        <div class="scope-options" id="scope-options"></div>
16810      </div>
16811      {% if total_scans > 0 %}
16812      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
16813        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
16814          <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>
16815          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
16816        </div>
16817      </div>
16818      {% endif %}
16819      <div class="table-wrap">
16820        <table id="compare-table">
16821          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
16822          <thead>
16823            <tr id="compare-thead">
16824              <th><div class="col-resize-handle"></div></th>
16825              <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>
16826              <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>
16827              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
16828              <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>
16829              <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>
16830              <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>
16831              <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>
16832              <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>
16833              <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>
16834              <th>Submodules<div class="col-resize-handle"></div></th>
16835            </tr>
16836          </thead>
16837          <tbody id="compare-tbody">
16838            {% for entry in entries %}
16839            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
16840                data-timestamp="{{ entry.timestamp }}"
16841                data-project="{{ entry.project_label }}"
16842                data-files="{{ entry.files_analyzed }}"
16843                data-code="{{ entry.code_lines }}"
16844                data-comments="{{ entry.comment_lines }}"
16845                data-blank="{{ entry.blank_lines }}"
16846                data-branch="{{ entry.git_branch }}"
16847                data-commit="{{ entry.git_commit }}"
16848                data-submodules="{{ entry.submodule_names_csv }}">
16849              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
16850              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
16851              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
16852              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
16853              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
16854              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
16855              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
16856              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
16857              <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>
16858              <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>
16859              <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>
16860            </tr>
16861            {% endfor %}
16862          </tbody>
16863        </table>
16864      </div>
16865      <div class="pagination">
16866        <span class="pagination-info" id="pagination-info"></span>
16867        <div class="pagination-btns" id="pagination-btns"></div>
16868        <div class="flex-row">
16869          <span class="per-page-label">Show</span>
16870          <select class="per-page" id="per-page-sel">
16871            <option value="10">10 per page</option>
16872            <option value="25" selected>25 per page</option>
16873            <option value="50">50 per page</option>
16874            <option value="100">100 per page</option>
16875          </select>
16876          <span class="per-page-label" id="page-range-label"></span>
16877        </div>
16878      </div>
16879      {% endif %}
16880    </section>
16881  </div>
16882
16883  <footer class="site-footer">
16884    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
16885    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16886    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16887    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16888    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16889  </footer>
16890
16891  <script nonce="{{ csp_nonce }}">
16892    (function () {
16893      // ── Theme ──────────────────────────────────────────────────────────────
16894      var storageKey = 'oxide-sloc-theme';
16895      var body = document.body;
16896      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16897      var toggle = document.getElementById('theme-toggle');
16898      if (toggle) toggle.addEventListener('click', function () {
16899        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16900        body.classList.toggle('dark-theme', next === 'dark');
16901        try { localStorage.setItem(storageKey, next); } catch(e) {}
16902      });
16903
16904      // ── State ─────────────────────────────────────────────────────────────
16905      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
16906      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
16907      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16908
16909      // ── Stat chips ────────────────────────────────────────────────────────
16910      (function() {
16911        var projects = {}, latestTs = '', latestRow = null;
16912        allRows.forEach(function(r) {
16913          var p = r.dataset.project || ''; if (p) projects[p] = true;
16914          var ts = r.dataset.timestamp || '';
16915          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
16916        });
16917        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();}
16918        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>':'');}
16919        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
16920        if (latestRow) {
16921          setChipVal('agg-code', latestRow.dataset.code);
16922          setChipVal('agg-files', latestRow.dataset.files);
16923        }
16924      })();
16925
16926      // ── Branch filter population ──────────────────────────────────────────
16927      (function() {
16928        var branches = {};
16929        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16930        var sel = document.getElementById('branch-filter');
16931        if (sel) Object.keys(branches).sort().forEach(function(b) {
16932          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16933        });
16934      })();
16935
16936      // ── Filter ────────────────────────────────────────────────────────────
16937      function getFilteredRows() {
16938        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16939        var branch = ((document.getElementById('branch-filter') || {}).value || '');
16940        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
16941          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16942          if (branch && (r.dataset.branch || '') !== branch) return false;
16943          return true;
16944        });
16945      }
16946
16947      // ── Pagination ────────────────────────────────────────────────────────
16948      function renderPage() {
16949        var filtered = getFilteredRows();
16950        var total = filtered.length;
16951        var totalPages = Math.max(1, Math.ceil(total / perPage));
16952        currentPage = Math.min(currentPage, totalPages);
16953        var start = (currentPage - 1) * perPage;
16954        var end = Math.min(start + perPage, total);
16955        var shown = {};
16956        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16957        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
16958          r.style.display = shown[r.dataset.run] ? '' : 'none';
16959        });
16960        var rl = document.getElementById('page-range-label');
16961        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16962        var info = document.getElementById('pagination-info');
16963        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16964        var btns = document.getElementById('pagination-btns');
16965        if (!btns) return;
16966        btns.innerHTML = '';
16967        function makeBtn(lbl, pg, active, disabled) {
16968          var b = document.createElement('button');
16969          b.className = 'pg-btn' + (active ? ' active' : '');
16970          b.textContent = lbl; b.disabled = disabled;
16971          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16972          return b;
16973        }
16974        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16975        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16976        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16977        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16978      }
16979
16980      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16981      window.applyFilters = function() { currentPage = 1; renderPage(); };
16982
16983      // ── Sorting ───────────────────────────────────────────────────────────
16984      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
16985      function doSort(col, type, order) {
16986        var tbody = document.getElementById('compare-tbody');
16987        if (!tbody) return;
16988        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16989        rows.sort(function(a, b) {
16990          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16991          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16992          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16993          return va < vb ? 1 : va > vb ? -1 : 0;
16994        });
16995        rows.forEach(function(r) { tbody.appendChild(r); });
16996        currentPage = 1; renderPage();
16997      }
16998      sortHeaders.forEach(function(th) {
16999        th.addEventListener('click', function(e) {
17000          if (e.target.classList.contains('col-resize-handle')) return;
17001          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
17002          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
17003          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17004          th.classList.add('sort-' + sortOrder);
17005          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
17006          doSort(col, type, sortOrder);
17007        });
17008      });
17009
17010      // Apply default sort (timestamp desc) on initial load
17011      (function() {
17012        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
17013        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
17014      })();
17015
17016      // ── Column resize ─────────────────────────────────────────────────────
17017      (function() {
17018        var table = document.getElementById('compare-table');
17019        if (!table) return;
17020        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
17021        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
17022        ths.forEach(function(th, i) {
17023          var handle = th.querySelector('.col-resize-handle');
17024          if (!handle || !cols[i]) return;
17025          var startX, startW;
17026          handle.addEventListener('mousedown', function(e) {
17027            e.stopPropagation(); e.preventDefault();
17028            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
17029            handle.classList.add('dragging');
17030            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
17031            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
17032            document.addEventListener('mousemove', onMove);
17033            document.addEventListener('mouseup', onUp);
17034          });
17035        });
17036      })();
17037
17038      // ── Reset view ────────────────────────────────────────────────────────
17039      window.resetView = function() {
17040        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
17041        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
17042        sortCol = null; sortOrder = 'asc';
17043        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17044        var tbody = document.getElementById('compare-tbody');
17045        if (tbody) {
17046          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
17047          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
17048          rows.forEach(function(r) { tbody.appendChild(r); });
17049        }
17050        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
17051        var table = document.getElementById('compare-table');
17052        currentPage = 1; renderPage();
17053        currentPage = 1; renderPage();
17054      };
17055
17056      renderPage();
17057
17058      // ── Row selection state ───────────────────────────────────────────────
17059      var selected = [];
17060      function updateCompareBtn() {
17061        var btn = document.getElementById('compare-btn');
17062        var cnt = document.getElementById('sel-count');
17063        if (!btn) return;
17064        btn.disabled = selected.length !== 2;
17065        if (cnt) cnt.textContent = selected.length + '/2';
17066      }
17067
17068      function toggleRow(row) {
17069        var vid = row.dataset.vid || row.dataset.run;
17070        var idx = selected.indexOf(vid);
17071        if (idx >= 0) {
17072          selected.splice(idx, 1);
17073          row.classList.remove('selected');
17074          var b = document.getElementById('badge-' + vid);
17075          if (b) b.textContent = '';
17076        } else {
17077          if (selected.length >= 2) return;
17078          selected.push(vid);
17079          row.classList.add('selected');
17080        }
17081        selected.forEach(function(v, i) {
17082          var b = document.getElementById('badge-' + v);
17083          if (b) b.textContent = i + 1;
17084        });
17085        updateCompareBtn();
17086        buildScopePanel();
17087      }
17088
17089      // ── Scope panel ───────────────────────────────────────────────────────
17090      var selectedScope = 'all';
17091
17092      function buildScopePanel() {
17093        var panel = document.getElementById('scope-panel');
17094        var opts = document.getElementById('scope-options');
17095        if (!panel || !opts) return;
17096        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
17097
17098        // Collect union of submodules from both selected rows.
17099        var allSubs = {};
17100        selected.forEach(function(vid) {
17101          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
17102          if (!row) return;
17103          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
17104        });
17105        var subList = Object.keys(allSubs).sort();
17106        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
17107
17108        panel.classList.remove('hidden');
17109        opts.innerHTML = '';
17110
17111        function makeOption(value, label, title) {
17112          var div = document.createElement('div');
17113          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
17114          div.dataset.scopeValue = value;
17115          if (title) div.title = title;
17116          var radio = document.createElement('span');
17117          radio.className = 'scope-option-radio';
17118          var lbl = document.createElement('span');
17119          lbl.textContent = label;
17120          div.appendChild(radio);
17121          div.appendChild(lbl);
17122          div.addEventListener('click', function() {
17123            selectedScope = value;
17124            opts.querySelectorAll('.scope-option').forEach(function(o) {
17125              o.classList.toggle('selected', o.dataset.scopeValue === value);
17126            });
17127          });
17128          return div;
17129        }
17130
17131        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
17132        var sep = document.createElement('span');
17133        sep.className = 'scope-option-sep';
17134        opts.appendChild(sep);
17135        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
17136        subList.forEach(function(s) {
17137          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
17138        });
17139      }
17140
17141      function doCompare() {
17142        if (selected.length !== 2) return;
17143        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
17144        if (selectedScope === 'super') url += '&scope=super';
17145        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
17146        window.location.href = url;
17147      }
17148
17149      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
17150      var cbtn = document.getElementById('compare-btn');
17151      if (cbtn) cbtn.addEventListener('click', doCompare);
17152      var pfEl = document.getElementById('project-filter');
17153      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
17154      var bfEl = document.getElementById('branch-filter');
17155      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
17156      var rvBtn = document.getElementById('reset-view-btn');
17157      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
17158      var ppSel = document.getElementById('per-page-sel');
17159      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
17160
17161      var cmpTbody = document.getElementById('compare-tbody');
17162      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
17163        var row = e.target.closest('.compare-row');
17164        if (row) toggleRow(row);
17165      });
17166
17167      (function randomizeWatermarks() {
17168        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17169        if (!wms.length) return;
17170        var placed = [];
17171        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;}
17172        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];}
17173        var half=Math.floor(wms.length/2);
17174        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;});
17175      })();
17176
17177      (function spawnCodeParticles() {
17178        var container = document.getElementById('code-particles');
17179        if (!container) return;
17180        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'];
17181        for (var i = 0; i < 38; i++) {
17182          (function(idx) {
17183            var el = document.createElement('span');
17184            el.className = 'code-particle';
17185            el.textContent = snippets[idx % snippets.length];
17186            var left = Math.random() * 94 + 2;
17187            var top = Math.random() * 88 + 6;
17188            var dur = (Math.random() * 10 + 9).toFixed(1);
17189            var delay = (Math.random() * 18).toFixed(1);
17190            var rot = (Math.random() * 26 - 13).toFixed(1);
17191            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17192            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';
17193            container.appendChild(el);
17194          })(i);
17195        }
17196      })();
17197
17198      // ── Watched folder picker ─────────────────────────────────────────────
17199      (function() {
17200        var btn = document.getElementById('add-watched-btn');
17201        if (!btn) return;
17202        btn.addEventListener('click', function() {
17203          fetch('/pick-directory?kind=reports')
17204            .then(function(r) { return r.json(); })
17205            .then(function(data) {
17206              if (!data.cancelled && data.selected_path) {
17207                var form = document.createElement('form');
17208                form.method = 'POST';
17209                form.action = '/watched-dirs/add';
17210                var ri = document.createElement('input');
17211                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
17212                var fi = document.createElement('input');
17213                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
17214                form.appendChild(ri); form.appendChild(fi);
17215                document.body.appendChild(form);
17216                form.submit();
17217              }
17218            })
17219            .catch(function(e) { alert('Could not open folder picker: ' + e); });
17220        });
17221      })();
17222
17223      // ── Submodule chip truncation ─────────────────────────────────────────
17224      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
17225        var chips = cell.querySelectorAll('.submod-chip');
17226        var MAX = 4;
17227        if (chips.length <= MAX) return;
17228        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
17229        var badge = document.createElement('span');
17230        badge.className = 'submod-overflow-badge';
17231        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
17232        badge.textContent = '+' + (chips.length - MAX) + ' more';
17233        cell.appendChild(badge);
17234        cell.style.maxHeight = 'none';
17235      });
17236    })();
17237  </script>
17238  <script nonce="{{ csp_nonce }}">
17239  (function(){
17240    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'}];
17241    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);});}
17242    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17243    function init(){
17244      var btn=document.getElementById('settings-btn');if(!btn)return;
17245      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17246      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>';
17247      document.body.appendChild(m);
17248      var g=document.getElementById('scheme-grid');
17249      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);});
17250      var cl=document.getElementById('settings-close');
17251      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);
17252      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');});
17253      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17254      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17255    }
17256    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17257  }());
17258  </script>
17259</body>
17260</html>
17261"##,
17262    ext = "html"
17263)]
17264struct CompareSelectTemplate {
17265    version: &'static str,
17266    entries: Vec<HistoryEntryRow>,
17267    total_scans: usize,
17268    watched_dirs: Vec<String>,
17269    csp_nonce: String,
17270}
17271
17272// ── CompareTemplate ────────────────────────────────────────────────────────────
17273
17274#[derive(Template)]
17275#[template(
17276    source = r##"
17277<!doctype html>
17278<html lang="en">
17279<head>
17280  <meta charset="utf-8">
17281  <meta name="viewport" content="width=device-width, initial-scale=1">
17282  <title>OxideSLOC | Scan Delta</title>
17283  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17284  <style nonce="{{ csp_nonce }}">
17285    :root {
17286      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
17287      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
17288      --nav:#283790; --nav-2:#013e6b;
17289      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
17290      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
17291      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
17292    }
17293    body.dark-theme {
17294      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
17295      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
17296    }
17297    *{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);}
17298    .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);}
17299    .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;}
17300    .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));}
17301    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17302    .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;}
17303    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
17304    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17305    @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; } }
17306    .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;}
17307    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
17308    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17309    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17310    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17311    .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;}
17312    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17313    .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);}
17314    .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;}
17315    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17316    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17317    .settings-modal-body{padding:14px 16px 16px;}
17318    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17319    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17320    .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;}
17321    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17322    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17323    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17324    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17325    .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;}
17326    .tz-select:focus{border-color:var(--oxide);}
17327    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
17328    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
17329    .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;}
17330    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
17331    .hero-body{display:block;}
17332    .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;}
17333    .btn-back:hover{background:var(--line);}
17334    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
17335    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
17336    .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;}
17337    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
17338    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;}
17339    .muted{color:var(--muted);font-size:14px;}
17340    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
17341    .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;}
17342    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
17343    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
17344    .vpill-arrow{font-size:20px;color:var(--muted);}
17345    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
17346    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
17347    .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;}
17348    .delta-card.delta-card-wide{padding:22px 24px;}
17349    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
17350    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
17351    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
17352    .delta-card-from{font-size:15px;color:var(--muted);}
17353    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
17354    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
17355    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
17356    .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%;}
17357    .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;}
17358    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
17359    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
17360    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
17361    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
17362    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
17363    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
17364    .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;}
17365    .meta-card-commit:hover{color:var(--oxide);}
17366    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
17367    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
17368    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
17369    .meta-value{color:var(--text);font-size:13px;}
17370    .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;}
17371    .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);}
17372    .delta-card:hover .dc-tip{display:block;}
17373    .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;}
17374    .export-btn:hover{background:var(--line);}
17375    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
17376    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
17377    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
17378    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
17379    .delta-card-change.zero{color:var(--muted);background:transparent;}
17380    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
17381    .delta-card-pct.pos{color:var(--pos);}
17382    .delta-card-pct.neg{color:var(--neg);}
17383    .delta-card-pct.zero{color:var(--muted);}
17384    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
17385    .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;}
17386    .insight-card.insight-flag{border-color:var(--oxide);}
17387    .insight-card:hover .dc-tip{display:block;}
17388    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
17389    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
17390    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
17391    .insight-label.flag{color:var(--oxide);}
17392    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
17393    .insight-val.pos{color:var(--pos);}
17394    .insight-val.neg{color:var(--neg);}
17395    .insight-val.high{color:#c0392a;}
17396    .insight-val.med{color:#926000;}
17397    .insight-val.low{color:var(--pos);}
17398    body.dark-theme .insight-val.high{color:#ff6b6b;}
17399    body.dark-theme .insight-val.med{color:#f0c060;}
17400    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
17401    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
17402    .fc-row{display:flex;align-items:center;gap:8px;}
17403    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
17404    .fc-label{color:var(--muted);}
17405    .fc-modified .fc-count{color:#926000;}
17406    .fc-added .fc-count{color:var(--pos);}
17407    .fc-removed .fc-count{color:var(--neg);}
17408    .fc-unchanged .fc-count{color:var(--muted);}
17409    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
17410    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
17411    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
17412    .chip.modified{background:#fff2d8;color:#926000;}
17413    .chip.added{background:#e8f5ed;color:#1a8f47;}
17414    .chip.removed{background:#fdeaea;color:#b33b3b;}
17415    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
17416    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
17417    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
17418    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
17419    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
17420    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
17421    .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;}
17422    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
17423    .tab-btn:hover:not(.active){background:var(--line);}
17424    .btn-reset{padding:6px 14px;border-radius:8px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
17425    .btn-reset:hover{background:var(--line);}
17426    .table-wrap{width:100%;overflow-x:auto;}
17427    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
17428    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;}
17429    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
17430    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
17431    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
17432    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
17433    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
17434    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
17435    tr:last-child td{border-bottom:none;}
17436    tr.row-added td{background:rgba(26,143,71,0.06);}
17437    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
17438    tr.row-modified td{background:rgba(146,96,0,0.05);}
17439    tr.row-unchanged td{opacity:.6;}
17440    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
17441    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
17442    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
17443    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
17444    .status-badge.modified{background:#fff2d8;color:#926000;}
17445    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
17446    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
17447    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
17448    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
17449    .delta-val{font-weight:700;}
17450    .delta-val.pos{color:var(--pos);}
17451    .delta-val.neg{color:var(--neg);}
17452    .delta-val.zero{color:var(--muted);}
17453    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
17454    .from-to strong{color:var(--text);}
17455    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17456    .site-footer a{color:var(--muted);}
17457    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
17458    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
17459    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17460    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17461    .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;}
17462    .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;}
17463    .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;}
17464    @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));}}
17465    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
17466    .path-link:hover{color:var(--oxide-2);}
17467    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
17468    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
17469    a.vpill-id:hover{color:var(--oxide);}
17470    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
17471    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
17472    .pagination-info{font-size:13px;color:var(--muted);}
17473    .pagination-btns{display:flex;gap:6px;}
17474    .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;}
17475    .pg-btn:hover:not(:disabled){background:var(--line);}
17476    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17477    .pg-btn:disabled{opacity:.35;cursor:default;}
17478    .per-page-label{font-size:13px;color:var(--muted);}
17479    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;}
17480    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17481    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
17482    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
17483    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
17484    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
17485    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
17486    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
17487    .tab-btn.tab-unchanged{color:var(--muted);}
17488    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
17489    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
17490    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
17491    .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;}
17492    .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;}
17493    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
17494    .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;}
17495    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
17496    .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;}
17497    .submod-scope-btn:hover{background:var(--line);}
17498    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17499    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
17500    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
17501    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
17502    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
17503    body.dark-theme .ic-card{background:var(--surface-2);}
17504    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
17505    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
17506    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
17507    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
17508    #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;}
17509  </style>
17510</head>
17511<body>
17512  <div class="background-watermarks" aria-hidden="true">
17513    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17514    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17515    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17516    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17517    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17518    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17519  </div>
17520  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17521  <div class="top-nav">
17522    <div class="top-nav-inner">
17523      <a class="brand" href="/">
17524        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17525        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
17526      </a>
17527      <div class="nav-right">
17528        <a class="nav-pill" href="/">Home</a>
17529        <div class="nav-dropdown">
17530          <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>
17531          <div class="nav-dropdown-menu">
17532            <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>
17533          </div>
17534        </div>
17535        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17536        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17537        <div class="nav-dropdown">
17538          <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>
17539          <div class="nav-dropdown-menu">
17540            <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>
17541          </div>
17542        </div>
17543        <div class="server-status-wrap">
17544          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
17545          <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>
17546        </div>
17547        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17548          <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>
17549        </button>
17550        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17551          <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>
17552          <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>
17553        </button>
17554      </div>
17555    </div>
17556  </div>
17557
17558  <div class="page">
17559    <section class="hero">
17560      <div class="hero-header">
17561        <div>
17562          <h1 class="delta-title">Scan Delta</h1>
17563          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
17564          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
17565            {% if let Some(sub) = active_submodule %}
17566            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
17567            {% else if super_scope_active %}
17568            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
17569            {% else %}
17570            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
17571            {% endif %}
17572            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
17573          </div>
17574        </div>
17575        <a class="btn-back" href="/compare-scans">
17576          <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>
17577          Compare Scans
17578        </a>
17579      </div>
17580      {% if has_any_submodule_data %}
17581      <div class="submod-scope-bar">
17582        <span class="submod-scope-label">
17583          <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>
17584          Scope:
17585        </span>
17586        <div class="submod-scope-divider"></div>
17587        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
17588           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
17589           title="All files — super-repo and all submodules combined">Full scan</a>
17590        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
17591           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
17592           title="Only files that are not part of any submodule">Super-repo only</a>
17593        {% for sub in submodule_options %}
17594        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
17595           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
17596           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
17597        {% endfor %}
17598      </div>
17599      {% endif %}
17600      <div class="hero-body">
17601      <div class="meta-strip">
17602        <div class="delta-card delta-card-meta">
17603          <div class="meta-card-header">
17604            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
17605            <div class="meta-card-project-col">
17606              <div class="meta-card-project">{{ project_name }}</div>
17607              {% if has_any_submodule_data %}
17608              {% if let Some(sub) = active_submodule %}
17609              <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>
17610              {% else if super_scope_active %}
17611              <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>
17612              {% else %}
17613              <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>
17614              {% endif %}
17615              {% endif %}
17616            </div>
17617          </div>
17618          {% if !baseline_git_commit.is_empty() %}
17619          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
17620          {% else %}
17621          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
17622          {% endif %}
17623          <div class="meta-card-rows">
17624            <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>
17625            <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>
17626            <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>
17627            <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>
17628            {% if let Some(tags) = baseline_git_tags %}
17629            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17630            {% endif %}
17631          </div>
17632        </div>
17633        <div class="delta-card delta-card-meta">
17634          <div class="meta-card-header">
17635            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
17636            <div class="meta-card-project-col">
17637              <div class="meta-card-project">{{ project_name }}</div>
17638              {% if has_any_submodule_data %}
17639              {% if let Some(sub) = active_submodule %}
17640              <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>
17641              {% else if super_scope_active %}
17642              <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>
17643              {% else %}
17644              <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>
17645              {% endif %}
17646              {% endif %}
17647            </div>
17648          </div>
17649          {% if !current_git_commit.is_empty() %}
17650          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
17651          {% else %}
17652          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
17653          {% endif %}
17654          <div class="meta-card-rows">
17655            <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>
17656            <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>
17657            <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>
17658            <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>
17659            {% if let Some(tags) = current_git_tags %}
17660            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17661            {% endif %}
17662          </div>
17663        </div>
17664      </div>
17665      <div class="delta-strip">
17666        <div class="delta-card">
17667          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
17668          <div class="delta-card-label">Code lines</div>
17669          <div class="delta-card-from">Before: {{ baseline_code }}</div>
17670          <div class="delta-card-to">{{ current_code }}</div>
17671          {% 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>
17672          {% 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>
17673          {% else %}<div class="delta-card-pct zero">±0%</div>
17674          {% endif %}
17675        </div>
17676        <div class="delta-card">
17677          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
17678          <div class="delta-card-label">Files analyzed</div>
17679          <div class="delta-card-from">Before: {{ baseline_files }}</div>
17680          <div class="delta-card-to">{{ current_files }}</div>
17681          {% 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>
17682          {% 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>
17683          {% else %}<div class="delta-card-pct zero">±0%</div>
17684          {% endif %}
17685        </div>
17686        <div class="delta-card">
17687          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
17688          <div class="delta-card-label">Comment lines</div>
17689          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
17690          <div class="delta-card-to">{{ current_comments }}</div>
17691          {% 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>
17692          {% 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>
17693          {% else %}<div class="delta-card-pct zero">±0%</div>
17694          {% endif %}
17695        </div>
17696        <div class="delta-card delta-card-wide">
17697          <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>
17698          <div class="delta-card-label">File changes</div>
17699          <div class="file-changes-grid">
17700            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
17701            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
17702            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
17703            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
17704          </div>
17705        </div>
17706      </div>
17707      <div class="insights-panel">
17708        <div class="insight-card">
17709          <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>
17710          <div class="insight-label">Lines Added</div>
17711          <div class="insight-val pos">+{{ code_lines_added }}</div>
17712          <div class="insight-sub">New or grown source lines</div>
17713        </div>
17714        <div class="insight-card">
17715          <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>
17716          <div class="insight-label">Lines Removed</div>
17717          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
17718          <div class="insight-sub">Deleted or shrunk source lines</div>
17719        </div>
17720        <div class="insight-card">
17721          <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>
17722          <div class="insight-label">Churn Rate</div>
17723          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
17724          <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>
17725        </div>
17726        {% if scope_flag %}
17727        <div class="insight-card insight-flag">
17728          <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>
17729          <div class="insight-label flag">Scope Signal</div>
17730          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
17731          <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>
17732        </div>
17733        {% endif %}
17734      </div>
17735      </div>
17736    </section>
17737
17738    <section class="panel" id="inline-charts-section">
17739      <h2>Scan Delta Charts</h2>
17740      <div class="ic-grid">
17741        <div class="ic-card">
17742          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
17743          <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>
17744          <div id="ic-c1"></div>
17745        </div>
17746        <div class="ic-card" id="ic-lang-card">
17747          <div class="ic-card-h2">Language Code Delta</div>
17748          <div id="ic-c3"></div>
17749        </div>
17750        <div class="ic-card">
17751          <div class="ic-card-h2">Delta by Metric</div>
17752          <div id="ic-c2"></div>
17753        </div>
17754        <div class="ic-card">
17755          <div class="ic-card-h2">File Change Distribution</div>
17756          <div id="ic-c4"></div>
17757        </div>
17758      </div>
17759    </section>
17760
17761    <section class="panel">
17762      <h2>File-level delta</h2>
17763      <div class="filter-tabs-row">
17764        <div class="filter-tabs">
17765          <button class="tab-btn tab-all active" data-filter="all">All</button>
17766          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
17767          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
17768          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
17769          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
17770        </div>
17771        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
17772          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
17773          <div class="export-group">
17774            <button type="button" class="btn-reset" id="delta-reset-btn">&#8635; Reset</button>
17775            <button type="button" class="export-btn" id="delta-csv-btn">
17776              <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>
17777              CSV
17778            </button>
17779            <button type="button" class="export-btn" id="delta-xls-btn">
17780              <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>
17781              Excel
17782            </button>
17783            <button type="button" class="export-btn" id="delta-charts-btn">
17784              <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>
17785              Charts
17786            </button>
17787          </div>
17788        </div>
17789      </div>
17790
17791      <div class="table-wrap">
17792      <table id="delta-table">
17793        <colgroup>
17794          <col>
17795          <col>
17796          <col>
17797          <col>
17798          <col>
17799          <col>
17800          <col>
17801        </colgroup>
17802        <thead>
17803          <tr id="delta-thead">
17804            <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>
17805            <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>
17806            <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>
17807            <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>
17808            <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>
17809            <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>
17810            <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>
17811          </tr>
17812        </thead>
17813        <tbody id="delta-tbody">
17814          {% for row in file_rows %}
17815          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
17816              data-path="{{ row.relative_path }}"
17817              data-language="{{ row.language }}"
17818              data-baseline-code="{{ row.baseline_code }}"
17819              data-current-code="{{ row.current_code }}"
17820              data-code-delta="{{ row.code_delta_str }}"
17821              data-comment-delta="{{ row.comment_delta_str }}"
17822              data-total-delta="{{ row.total_delta_str }}"
17823              data-orig-idx="">
17824            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
17825            <td class="hide-sm">{{ row.language }}</td>
17826            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
17827            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
17828            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
17829            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
17830            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
17831          </tr>
17832          {% endfor %}
17833        </tbody>
17834      </table>
17835      </div>
17836      <div class="pagination">
17837        <span class="pagination-info" id="pg-info"></span>
17838        <div class="pagination-btns" id="pg-btns"></div>
17839        <div class="flex-row">
17840          <span class="per-page-label">Show</span>
17841          <select class="per-page" id="per-page-sel">
17842            <option value="10">10 per page</option>
17843            <option value="25" selected>25 per page</option>
17844            <option value="50">50 per page</option>
17845            <option value="100">100 per page</option>
17846          </select>
17847          <span class="per-page-label" id="pg-range-label"></span>
17848        </div>
17849      </div>
17850    </section>
17851  </div>
17852
17853  <div id="ic-tt"></div>
17854
17855  <footer class="site-footer">
17856    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
17857    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17858    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17859    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17860    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17861  </footer>
17862
17863  <script nonce="{{ csp_nonce }}">
17864    (function () {
17865      var storageKey = 'oxide-sloc-theme';
17866      var body = document.body;
17867      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17868      var toggle = document.getElementById('theme-toggle');
17869      if (toggle) toggle.addEventListener('click', function () {
17870        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17871        body.classList.toggle('dark-theme', next === 'dark');
17872        try { localStorage.setItem(storageKey, next); } catch(e) {}
17873      });
17874
17875      (function randomizeWatermarks() {
17876        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17877        if (!wms.length) return;
17878        var placed = [];
17879        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;}
17880        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];}
17881        var half=Math.floor(wms.length/2);
17882        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;});
17883      })();
17884
17885      (function spawnCodeParticles() {
17886        var container = document.getElementById('code-particles');
17887        if (!container) return;
17888        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'];
17889        for (var i = 0; i < 38; i++) {
17890          (function(idx) {
17891            var el = document.createElement('span');
17892            el.className = 'code-particle';
17893            el.textContent = snippets[idx % snippets.length];
17894            var left = Math.random() * 94 + 2;
17895            var top = Math.random() * 88 + 6;
17896            var dur = (Math.random() * 10 + 9).toFixed(1);
17897            var delay = (Math.random() * 18).toFixed(1);
17898            var rot = (Math.random() * 26 - 13).toFixed(1);
17899            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17900            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';
17901            container.appendChild(el);
17902          })(i);
17903        }
17904      })();
17905    })();
17906
17907    var activeStatusFilter = 'all';
17908    var deltaPerPage = 25, deltaCurrPage = 1;
17909
17910    function openFolder(path) {
17911      fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
17912    }
17913
17914    function getDeltaFilteredRows() {
17915      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
17916        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
17917      });
17918    }
17919
17920    function renderDeltaPage() {
17921      var filtered = getDeltaFilteredRows();
17922      var total = filtered.length;
17923      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
17924      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
17925      var start = (deltaCurrPage - 1) * deltaPerPage;
17926      var end = Math.min(start + deltaPerPage, total);
17927      var shownSet = {};
17928      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
17929      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
17930        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
17931      });
17932      var rl = document.getElementById('pg-range-label');
17933      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
17934      var info = document.getElementById('pg-info');
17935      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
17936      var btns = document.getElementById('pg-btns');
17937      if (!btns) return;
17938      btns.innerHTML = '';
17939      if (totalPages <= 1) return;
17940      function makeBtn(lbl, pg, active, disabled) {
17941        var b = document.createElement('button');
17942        b.className = 'pg-btn' + (active ? ' active' : '');
17943        b.textContent = lbl; b.disabled = disabled;
17944        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
17945        return b;
17946      }
17947      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
17948      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
17949      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
17950      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
17951    }
17952
17953    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
17954
17955    function filterRows(status, btn) {
17956      activeStatusFilter = status;
17957      deltaCurrPage = 1;
17958      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
17959        b.classList.remove('active');
17960      });
17961      if (btn) btn.classList.add('active');
17962      renderDeltaPage();
17963    }
17964
17965    // ── Sorting ──────────────────────────────────────────────────────────────
17966    var sortCol = null, sortOrder = 'asc';
17967    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
17968    (function() {
17969      var tbody = document.getElementById('delta-tbody');
17970      if (!tbody) return;
17971      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17972      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
17973    })();
17974
17975    function parseDeltaNum(str) {
17976      if (!str || str === '—') return 0;
17977      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
17978    }
17979
17980    sortHeaders.forEach(function(th) {
17981      th.addEventListener('click', function(e) {
17982        if (e.target.classList.contains('col-resize-handle')) return;
17983        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
17984        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
17985        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17986        th.classList.add('sort-' + sortOrder);
17987        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
17988        var tbody = document.getElementById('delta-tbody');
17989        if (!tbody) return;
17990        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17991        rows.sort(function(a, b) {
17992          var va, vb;
17993          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
17994          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
17995          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
17996          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
17997          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17998          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17999          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
18000          else { va = ''; vb = ''; }
18001          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
18002          return va < vb ? 1 : va > vb ? -1 : 0;
18003        });
18004        rows.forEach(function(r) { tbody.appendChild(r); });
18005        deltaCurrPage = 1;
18006        renderDeltaPage();
18007        var activeBtn = document.querySelector('.tab-btn.active');
18008        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
18009        if (activeBtn) activeBtn.classList.add('active');
18010      });
18011    });
18012
18013    // ── Column resize ─────────────────────────────────────────────────────────
18014    (function() {
18015      var table = document.getElementById('delta-table');
18016      if (!table) return;
18017      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
18018      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
18019      ths.forEach(function(th, i) {
18020        var handle = th.querySelector('.col-resize-handle');
18021        if (!handle || !cols[i]) return;
18022        var startX, startW;
18023        handle.addEventListener('mousedown', function(e) {
18024          e.stopPropagation(); e.preventDefault();
18025          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
18026          handle.classList.add('dragging');
18027          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
18028          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
18029          document.addEventListener('mousemove', onMove);
18030          document.addEventListener('mouseup', onUp);
18031        });
18032      });
18033    })();
18034
18035    // ── Reset ─────────────────────────────────────────────────────────────────
18036    window.resetDeltaTable = function() {
18037      sortCol = null; sortOrder = 'asc';
18038      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18039      var tbody = document.getElementById('delta-tbody');
18040      if (tbody) {
18041        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
18042        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
18043        rows.forEach(function(r) { tbody.appendChild(r); });
18044      }
18045      var table = document.getElementById('delta-table');
18046      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
18047      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
18048      activeStatusFilter = 'all';
18049      deltaCurrPage = 1;
18050      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
18051      var allBtn = document.querySelector('.tab-btn');
18052      if (allBtn) allBtn.classList.add('active');
18053      renderDeltaPage();
18054    };
18055
18056    renderDeltaPage();
18057
18058    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
18059    (function() {
18060      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
18061        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
18062      });
18063      var resetBtn = document.getElementById('delta-reset-btn');
18064      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
18065      var csvBtn = document.getElementById('delta-csv-btn');
18066      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
18067      var xlsBtn = document.getElementById('delta-xls-btn');
18068      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
18069      var chartsBtn = document.getElementById('delta-charts-btn');
18070      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
18071      var ppSel = document.getElementById('per-page-sel');
18072      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
18073      var pathLink = document.getElementById('project-path-link');
18074      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
18075    })();
18076
18077    // ── Export helpers ────────────────────────────────────────────────────────
18078    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
18079    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
18080    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);}
18081    function slocMakeXlsx(fname,sd,dr){
18082      var enc=new TextEncoder();
18083      // CRC-32 table
18084      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;}
18085      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;}
18086      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
18087      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
18088      // Shared string table
18089      var ss=[],si={};
18090      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
18091      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18092      // Worksheet builder — each WS() call gets its own row counter R
18093      function WS(){
18094        var R=0,buf=[];
18095        function cl(c){return String.fromCharCode(65+c);}
18096        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
18097          '<v>'+S(v)+'</v></c>';}
18098        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
18099          (st?' s="'+st+'"':'')+'>'+
18100          '<v>'+(+v)+'</v></c>';}
18101        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
18102        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
18103          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
18104          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
18105          '<sheetFormatPr defaultRowHeight="15"/>'+
18106          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
18107        return{sc:sc,nc:nc,row:row,xml:xml};
18108      }
18109      // Language breakdown
18110      var lm={};
18111      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;});
18112      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
18113      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
18114      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
18115      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
18116      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
18117      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
18118      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):'';}
18119      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
18120      // Summary sheet
18121      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
18122      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
18123      r1(s1(0,proj,2));
18124      r1(s1(0,sd.bts+' → '+sd.cts,2));
18125      r1('');
18126      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
18127      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))));
18128      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))));
18129      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))));
18130      r1('');
18131      r1(s1(0,'FILE CHANGES',8));
18132      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
18133      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
18134      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
18135      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
18136      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
18137      if(langs.length){
18138        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
18139        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
18140        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)));});
18141      }
18142      r1('');r1(s1(0,'SCAN METADATA',8));
18143      r1(s1(1,_blabel)+s1(2,_clabel));
18144      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
18145      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
18146      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"/>');
18147      // File Delta sheet
18148      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
18149      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));
18150      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)));});
18151      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
18152      // Shared strings XML
18153      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
18154        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
18155        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
18156      // XLSX file map
18157      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
18158      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>',
18159        '_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>',
18160        '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>',
18161        '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>',
18162        '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>',
18163        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
18164      // ZIP packer — STORED (no compression), compatible with all XLSX readers
18165      var zparts=[],zcds=[],zoff=0,znf=0;
18166      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
18167       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
18168      ].forEach(function(name){
18169        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
18170        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]);
18171        var entry=new Uint8Array(lha.length+nb.length+sz);
18172        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
18173        zparts.push(entry);
18174        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));
18175        var cde=new Uint8Array(cda.length+nb.length);
18176        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
18177        zcds.push(cde);zoff+=entry.length;znf++;
18178      });
18179      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
18180      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]);
18181      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
18182      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
18183      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
18184      zout.set(new Uint8Array(ea),zpos);
18185      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
18186      var xurl=URL.createObjectURL(xblob);
18187      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
18188      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
18189      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
18190    }
18191    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;');}
18192    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
18193    function getExportFilename(ext){return _exportBase+'.'+ext;}
18194
18195    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 }}'};
18196    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;}
18197    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
18198    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
18199    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
18200    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
18201    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):'';}
18202    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
18203    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)]];}
18204    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
18205    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;}
18206    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
18207    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
18208
18209    // ── Chart HTML report ─────────────────────────────────────────────────────
18210    function slocChartReport(fname, sd, dr) {
18211      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
18212      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18213      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18214      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();}
18215      function px(n){return Math.round(n);}
18216      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
18217      // Language map
18218      var lm={};
18219      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;});
18220      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18221
18222      // Builds onmouse* attrs for interactive tooltip on each SVG element
18223      function barTT(label,val){
18224        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
18225      }
18226
18227      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
18228      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'}];
18229      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18230      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
18231      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
18232      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18233      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"/>';}
18234      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18235      c1mets.forEach(function(m,i){
18236        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18237        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18238        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>';
18239        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))+'/>';
18240        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>';
18241        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))+'/>';
18242        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>';
18243        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>';
18244        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>';
18245      });
18246      c1+='</svg>';
18247
18248      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
18249      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'}];
18250      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18251      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
18252      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
18253      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18254      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18255      mets.forEach(function(m,i){
18256        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
18257        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
18258        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
18259        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>';
18260        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
18261        if(bw>=52){
18262          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>';
18263        }else{
18264          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
18265          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>';
18266        }
18267      });
18268      c2+='</svg>';
18269
18270      // ── Chart 3: Language Code Delta ─────────────────────────────────────
18271      var c3='';
18272      if(langs.length){
18273        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18274        var C3W=550,c3LW=124,c3FW=52;
18275        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
18276        var L3rH=30,C3H=langs.length*L3rH+20;
18277        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18278        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18279        langs.forEach(function(l,i){
18280          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
18281          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
18282          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
18283          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18284          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':''))+'/>';
18285          if(bw>=48){
18286            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>';
18287          }else{
18288            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
18289            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>';
18290          }
18291          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>';
18292        });
18293        c3+='</svg>';
18294      }
18295
18296      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
18297      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;});
18298      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18299      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
18300      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18301      var ang=-Math.PI/2;
18302      segs.forEach(function(s){
18303        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18304        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
18305        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
18306        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
18307        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
18308        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)+'%')+'/>';
18309        ang+=sw;
18310      });
18311      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>';
18312      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18313      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>';});
18314      c4+='</svg>';
18315
18316      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
18317      var ttJs='var tt=document.getElementById("ox-tt");'+
18318        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
18319        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
18320        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
18321        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
18322        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
18323        'function oxHT(){tt.style.display="none";}';
18324
18325      // body max-width keeps charts from inflating beyond design dimensions on
18326      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
18327      // each chart's height blows up proportionally, breaking the one-page layout.
18328      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;}'+
18329        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
18330        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
18331        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
18332        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
18333        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
18334        'svg{display:block;}'+
18335        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
18336        '#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;}'+
18337        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
18338      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
18339        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
18340        '<div id="ox-tt"><\/div>'+
18341        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
18342        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
18343        '<div class="two-col">'+
18344        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
18345        '<div class="leg">'+
18346        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
18347        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
18348        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
18349        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
18350        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
18351        '<\/div>'+
18352        '<div class="two-col">'+
18353        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
18354        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
18355        '<\/div>'+
18356        '<script>'+ttJs+'<\/script>'+
18357        '<\/body><\/html>';
18358      slocDownload(html, fname, 'text/html;charset=utf-8;');
18359    }
18360    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
18361    // ── Inline delta charts ────────────────────────────────────────────────────
18362    var _icTT=document.getElementById('ic-tt');
18363    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
18364    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';};
18365    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
18366    (function(){
18367      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
18368      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18369      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();}
18370      function px(n){return Math.round(n);}
18371      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18372      function btt(l,v){return ' class="ic-cb" onmouseover="icTT(event,\''+jsq(l)+'\',\''+jsq(v)+'\')" onmouseout="icHT()" onmousemove="icMT(event)"';}
18373      var dr=getDeltaExportRows(),sd=_sd,lm={};
18374      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;});
18375      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18376      // Chart 1: Baseline vs Current grouped bars
18377      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'}];
18378      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18379      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;
18380      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18381      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"/>';}
18382      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18383      c1mets.forEach(function(m,i){
18384        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18385        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18386        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>';
18387        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"/>';
18388        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>';
18389        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"/>';
18390        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>';
18391        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>';
18392        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>';
18393      });
18394      c1+='</svg>';
18395      // Chart 2: Delta by Metric
18396      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'}];
18397      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18398      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;
18399      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18400      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18401      mets.forEach(function(m,i){
18402        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);
18403        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>';
18404        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
18405        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>';}
18406        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>';}
18407      });
18408      c2+='</svg>';
18409      // Chart 3: Language Code Delta
18410      var c3='';
18411      if(langs.length){
18412        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18413        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;
18414        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18415        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18416        langs.forEach(function(l,i){
18417          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);
18418          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18419          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"/>';
18420          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>';}
18421          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>';}
18422          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>';
18423        });
18424        c3+='</svg>';
18425      }
18426      // Chart 4: File Change Donut
18427      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;});
18428      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18429      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;
18430      if(segs.length===1){
18431        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
18432        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
18433        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
18434      } else {
18435        segs.forEach(function(s){
18436          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18437          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);
18438          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);
18439          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"/>';
18440          ang+=sw;
18441        });
18442      }
18443      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>';
18444      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18445      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>';});
18446      c4+='</svg>';
18447      var e1=document.getElementById('ic-c1');if(e1)e1.innerHTML=c1;
18448      var e2=document.getElementById('ic-c2');if(e2)e2.innerHTML=c2;
18449      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>';
18450      var e4=document.getElementById('ic-c4');if(e4)e4.innerHTML=c4;
18451      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
18452    })();
18453  </script>
18454  <script nonce="{{ csp_nonce }}">
18455  (function(){
18456    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'}];
18457    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);});}
18458    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18459    function init(){
18460      var btn=document.getElementById('settings-btn');if(!btn)return;
18461      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18462      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>';
18463      document.body.appendChild(m);
18464      var g=document.getElementById('scheme-grid');
18465      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);});
18466      var cl=document.getElementById('settings-close');
18467      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);
18468      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');});
18469      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18470      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18471    }
18472    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18473  }());
18474  </script>
18475</body>
18476</html>
18477"##,
18478    ext = "html"
18479)]
18480// Template structs need many bool fields to pass Askama rendering flags.
18481#[allow(clippy::struct_excessive_bools)]
18482struct CompareTemplate {
18483    version: &'static str,
18484    project_label: String,
18485    baseline_git_commit: String,
18486    current_git_commit: String,
18487    baseline_run_id: String,
18488    current_run_id: String,
18489    baseline_run_id_short: String,
18490    current_run_id_short: String,
18491    baseline_timestamp: String,
18492    baseline_timestamp_utc_ms: i64,
18493    current_timestamp: String,
18494    current_timestamp_utc_ms: i64,
18495    project_path: String,
18496    baseline_code: u64,
18497    current_code: u64,
18498    code_lines_delta_str: String,
18499    code_lines_delta_class: String,
18500    baseline_files: u64,
18501    current_files: u64,
18502    files_analyzed_delta_str: String,
18503    files_analyzed_delta_class: String,
18504    baseline_comments: u64,
18505    current_comments: u64,
18506    comment_lines_delta_str: String,
18507    comment_lines_delta_class: String,
18508    code_lines_pct_str: String,
18509    files_analyzed_pct_str: String,
18510    comment_lines_pct_str: String,
18511    code_lines_added: i64,
18512    code_lines_removed: i64,
18513    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
18514    new_scope: bool,
18515    churn_rate_str: String,
18516    churn_rate_class: String,
18517    scope_flag: bool,
18518    files_added: usize,
18519    files_removed: usize,
18520    files_modified: usize,
18521    files_unchanged: usize,
18522    file_rows: Vec<CompareFileDeltaRow>,
18523    baseline_git_author: Option<String>,
18524    current_git_author: Option<String>,
18525    baseline_git_branch: String,
18526    current_git_branch: String,
18527    baseline_git_tags: Option<String>,
18528    current_git_tags: Option<String>,
18529    baseline_git_commit_date: Option<String>,
18530    current_git_commit_date: Option<String>,
18531    project_name: String,
18532    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
18533    submodule_options: Vec<String>,
18534    /// True when either run has submodule data — controls whether the scope bar is shown.
18535    has_any_submodule_data: bool,
18536    /// The submodule currently being compared, if the `sub` query param was provided.
18537    active_submodule: Option<String>,
18538    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
18539    super_scope_active: bool,
18540    csp_nonce: String,
18541}
18542
18543// ── LoginTemplate ──────────────────────────────────────────────────────────────
18544
18545#[derive(Template)]
18546#[template(
18547    source = r##"
18548<!doctype html>
18549<html lang="en">
18550<head>
18551  <meta charset="utf-8">
18552  <meta name="viewport" content="width=device-width, initial-scale=1">
18553  <title>OxideSLOC | Sign In</title>
18554  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18555  <style nonce="{{ csp_nonce }}">
18556    :root {
18557      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
18558      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
18559      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
18560      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
18561    }
18562    *{box-sizing:border-box;}
18563    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);}
18564    .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);}
18565    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
18566    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
18567    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
18568    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18569    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18570    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18571    .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;}
18572    @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));}}
18573    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
18574    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
18575    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18576    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
18577    .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;}
18578    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
18579    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;}
18580    input[type=password]:focus{border-color:var(--oxide);}
18581    .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;}
18582    .btn:hover{opacity:.88;}
18583    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
18584    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
18585  </style>
18586</head>
18587<body>
18588  <div class="background-watermarks" aria-hidden="true">
18589    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18590    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18591    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18592    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18593    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18594    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18595    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18596  </div>
18597  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18598<nav class="top-nav">
18599  <a class="brand" href="/">
18600    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
18601    <span class="brand-title">OxideSLOC</span>
18602  </a>
18603</nav>
18604<main class="page">
18605  <div class="card">
18606    <h1>Sign In</h1>
18607    <p class="subtitle">Enter the API key printed when the server started.</p>
18608    {% if has_error %}
18609    <div class="error">Incorrect API key — please try again.</div>
18610    {% endif %}
18611    <form method="POST" action="/auth/login">
18612      <input type="hidden" name="next" value="{{ next_url|e }}">
18613      <label for="key">API Key</label>
18614      <input id="key" type="password" name="key" autocomplete="current-password"
18615             placeholder="Paste your API key here" autofocus>
18616      <button type="submit" class="btn">Sign In</button>
18617    </form>
18618    <p class="hint">
18619      The API key was printed in the terminal when the server started.<br>
18620      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
18621      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
18622    </p>
18623  </div>
18624</main>
18625<script nonce="{{ csp_nonce }}">
18626(function() {
18627  (function randomizeWatermarks() {
18628    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18629    if (!wms.length) return;
18630    var placed = [];
18631    function tooClose(top, left) {
18632      for (var i = 0; i < placed.length; i++) {
18633        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
18634        if (dt < 16 && dl < 12) return true;
18635      }
18636      return false;
18637    }
18638    function pick(leftBand) {
18639      for (var attempt = 0; attempt < 50; attempt++) {
18640        var top = Math.random() * 88 + 2;
18641        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18642        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18643      }
18644      var top = Math.random() * 88 + 2;
18645      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18646      placed.push([top, left]); return [top, left];
18647    }
18648    var half = Math.floor(wms.length / 2);
18649    wms.forEach(function (img, i) {
18650      var pos = pick(i < half);
18651      var size = Math.floor(Math.random() * 100 + 120);
18652      var rot = (Math.random() * 360).toFixed(1);
18653      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
18654      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;
18655    });
18656  })();
18657  (function spawnCodeParticles() {
18658    var container = document.getElementById('code-particles');
18659    if (!container) return;
18660    var snippets = [
18661      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
18662      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
18663      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
18664      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
18665      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
18666    ];
18667    var count = 38;
18668    for (var i = 0; i < count; i++) {
18669      (function(idx) {
18670        var el = document.createElement('span');
18671        el.className = 'code-particle';
18672        el.textContent = snippets[idx % snippets.length];
18673        var left = Math.random() * 94 + 2;
18674        var top = Math.random() * 88 + 6;
18675        var dur = (Math.random() * 10 + 9).toFixed(1);
18676        var delay = (Math.random() * 18).toFixed(1);
18677        var rot = (Math.random() * 26 - 13).toFixed(1);
18678        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18679        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
18680        container.appendChild(el);
18681      })(i);
18682    }
18683  })();
18684})();
18685</script>
18686</body>
18687</html>
18688"##,
18689    ext = "html"
18690)]
18691struct LoginTemplate {
18692    csp_nonce: String,
18693    has_error: bool,
18694    next_url: String,
18695    lockout_threshold: u32,
18696}
18697
18698// ── REST API reference page ────────────────────────────────────────────────────
18699
18700#[derive(Template)]
18701#[template(
18702    source = r##"
18703<!doctype html>
18704<html lang="en">
18705<head>
18706  <meta charset="utf-8">
18707  <meta name="viewport" content="width=device-width, initial-scale=1">
18708  <title>OxideSLOC — REST API Reference</title>
18709  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18710  <style nonce="{{ csp_nonce }}">
18711    :root {
18712      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18713      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18714      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18715      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18716      --success:#16a34a;
18717    }
18718    body.dark-theme {
18719      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
18720      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
18721    }
18722    *{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);}
18723    .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);}
18724    .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;}
18725    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
18726    .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));}
18727    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
18728    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
18729    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
18730    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
18731    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18732    @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; } }
18733    .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;}
18734    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
18735    .nav-pill.active{background:rgba(255,255,255,0.22);}
18736    .nav-dropdown{position:relative;display:inline-flex;}
18737    .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;}
18738    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
18739    .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;}
18740    .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;}
18741    .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);}
18742    .nav-dropdown-menu a:last-child{border-bottom:none;}
18743    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
18744    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
18745    .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;}
18746    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18747    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18748    .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;}
18749    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18750    .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);}
18751    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
18752    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
18753    .settings-modal-body{padding:14px 16px 16px;}
18754    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18755    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18756    .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;}
18757    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18758    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18759    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18760    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18761    .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;}
18762    .tz-select:focus{border-color:var(--oxide);}
18763    .page{max-width:960px;margin:0 auto;padding:40px 24px 60px;position:relative;z-index:1;}
18764    .page-header{margin-bottom:28px;}
18765    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
18766    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
18767    .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;}
18768    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
18769    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
18770    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
18771    .callout strong{font-weight:800;}
18772    .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;}
18773    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
18774    .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;}
18775    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
18776    .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;}
18777    body.dark-theme .base-url-value{color:var(--accent);}
18778    .section{margin-bottom:36px;}
18779    .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);}
18780    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
18781    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
18782    .ep-header:hover{background:var(--surface-2);}
18783    .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;}
18784    .method.get{background:#dcfce7;color:#166534;}
18785    .method.post{background:#dbeafe;color:#1e40af;}
18786    .method.delete{background:#fee2e2;color:#991b1b;}
18787    body.dark-theme .method.get{background:#14532d;color:#86efac;}
18788    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
18789    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
18790    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
18791    .ep-path .param{color:var(--oxide-2);}
18792    body.dark-theme .ep-path .param{color:var(--oxide);}
18793    .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;}
18794    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
18795    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
18796    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
18797    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
18798    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
18799    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
18800    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
18801    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
18802    .ep-card.open .chevron{transform:rotate(180deg);}
18803    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
18804    .ep-card.open .ep-body{display:block;}
18805    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
18806    .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;}
18807    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
18808    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
18809    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18810    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
18811    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);}
18812    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
18813    table.params tr:last-child td{border-bottom:none;}
18814    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
18815    .pt-type{color:var(--muted-2);font-size:12px;}
18816    .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;}
18817    .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;}
18818    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
18819    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
18820    details.schema{margin-bottom:14px;}
18821    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;}
18822    details.schema summary:hover{color:var(--text);}
18823    .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;}
18824    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18825    .curl-wrap{position:relative;}
18826    .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;}
18827    .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;}
18828    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
18829    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
18830    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
18831    .webhook-note a{color:var(--accent-2);text-decoration:none;}
18832    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18833    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18834    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18835    .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;}
18836    @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));}}
18837    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18838    .site-footer a{color:var(--muted);}
18839  </style>
18840</head>
18841<body>
18842  <div class="background-watermarks" aria-hidden="true">
18843    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18844    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18845    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18846    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18847    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18848    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18849    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18850  </div>
18851  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18852  <div class="top-nav">
18853    <div class="top-nav-inner">
18854      <a class="brand" href="/">
18855        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18856        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
18857      </a>
18858      <div class="nav-right">
18859        <a class="nav-pill" href="/">Home</a>
18860        <div class="nav-dropdown">
18861          <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>
18862          <div class="nav-dropdown-menu">
18863            <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>
18864          </div>
18865        </div>
18866        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18867        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18868        <div class="nav-dropdown">
18869          <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>
18870          <div class="nav-dropdown-menu">
18871            <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>
18872          </div>
18873        </div>
18874        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18875          <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>
18876        </button>
18877        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18878          <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>
18879          <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>
18880        </button>
18881      </div>
18882    </div>
18883  </div>
18884
18885  <div class="page">
18886    <div class="page-header">
18887      <h1 class="page-title">REST API Reference</h1>
18888      <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>
18889    </div>
18890
18891    {% if has_api_key %}
18892    <div class="callout key-set">
18893      <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>
18894      <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>
18895    </div>
18896    {% else %}
18897    <div class="callout no-key">
18898      <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>
18899      <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>
18900    </div>
18901    {% endif %}
18902
18903    <div class="base-url-bar">
18904      <span class="base-url-label">Base URL</span>
18905      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
18906    </div>
18907
18908    <!-- Health -->
18909    <div class="section">
18910      <h2 class="section-title">Health &amp; Status</h2>
18911      <div class="ep-card">
18912        <div class="ep-header">
18913          <span class="method get">GET</span>
18914          <span class="ep-path">/healthz</span>
18915          <span class="auth-badge public">Public</span>
18916          <span class="ep-desc">Server liveness check</span>
18917          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18918        </div>
18919        <div class="ep-body">
18920          <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>
18921          <p class="params-heading">Response</p>
18922          <div class="schema-block">200 OK
18923Content-Type: text/plain
18924
18925ok</div>
18926          <p class="curl-heading">Example</p>
18927          <div class="curl-wrap">
18928            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
18929            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
18930          </div>
18931        </div>
18932      </div>
18933    </div>
18934
18935    <!-- Badges -->
18936    <div class="section">
18937      <h2 class="section-title">Badges</h2>
18938      <div class="ep-card">
18939        <div class="ep-header">
18940          <span class="method get">GET</span>
18941          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
18942          <span class="auth-badge public">Public</span>
18943          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
18944          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18945        </div>
18946        <div class="ep-body">
18947          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
18948          <p class="params-heading">Path Parameters</p>
18949          <table class="params">
18950            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18951            <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>
18952          </table>
18953          <p class="curl-heading">Example</p>
18954          <div class="curl-wrap">
18955            <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>
18956            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
18957          </div>
18958        </div>
18959      </div>
18960    </div>
18961
18962    <!-- Metrics -->
18963    <div class="section">
18964      <h2 class="section-title">Metrics</h2>
18965
18966      <div class="ep-card">
18967        <div class="ep-header">
18968          <span class="method get">GET</span>
18969          <span class="ep-path">/api/metrics/latest</span>
18970          <span class="auth-badge protected">Protected</span>
18971          <span class="ep-desc">Latest scan metrics (JSON)</span>
18972          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18973        </div>
18974        <div class="ep-body">
18975          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
18976          <details class="schema"><summary>Response schema</summary>
18977<div class="schema-block">{
18978  "run_id":    string,        // UUID
18979  "timestamp": string,        // ISO-8601 UTC
18980  "project":   string,        // scanned root path
18981  "summary": {
18982    "files_analyzed":       number,
18983    "files_skipped":        number,
18984    "code_lines":           number,
18985    "comment_lines":        number,
18986    "blank_lines":          number,
18987    "total_physical_lines": number,
18988    "functions":            number,
18989    "classes":              number,
18990    "variables":            number,
18991    "imports":              number
18992  },
18993  "languages": [
18994    { "name": string, "files": number, "code_lines": number,
18995      "comment_lines": number, "blank_lines": number,
18996      "functions": number, "classes": number,
18997      "variables": number, "imports": number }
18998  ]
18999}</div></details>
19000          <p class="curl-heading">Example</p>
19001          <div class="curl-wrap">
19002            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19003  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
19004            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
19005          </div>
19006        </div>
19007      </div>
19008
19009      <div class="ep-card">
19010        <div class="ep-header">
19011          <span class="method get">GET</span>
19012          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
19013          <span class="auth-badge protected">Protected</span>
19014          <span class="ep-desc">Metrics for a specific run</span>
19015          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19016        </div>
19017        <div class="ep-body">
19018          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
19019          <p class="params-heading">Path Parameters</p>
19020          <table class="params">
19021            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19022            <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>
19023          </table>
19024          <p class="curl-heading">Example</p>
19025          <div class="curl-wrap">
19026            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19027  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
19028            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
19029          </div>
19030        </div>
19031      </div>
19032
19033      <div class="ep-card">
19034        <div class="ep-header">
19035          <span class="method get">GET</span>
19036          <span class="ep-path">/api/metrics/history</span>
19037          <span class="auth-badge protected">Protected</span>
19038          <span class="ep-desc">Paginated scan history</span>
19039          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19040        </div>
19041        <div class="ep-body">
19042          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
19043          <p class="params-heading">Query Parameters</p>
19044          <table class="params">
19045            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19046            <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>
19047            <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>
19048          </table>
19049          <details class="schema"><summary>Response schema</summary>
19050<div class="schema-block">[{
19051  "run_id":         string,
19052  "timestamp":      string,   // ISO-8601 UTC
19053  "commit":         string | null,
19054  "branch":         string | null,
19055  "tags":           string[],
19056  "code_lines":     number,
19057  "comment_lines":  number,
19058  "blank_lines":    number,
19059  "physical_lines": number,
19060  "files_analyzed": number,
19061  "project_label":  string,
19062  "html_url":       string | null
19063}]</div></details>
19064          <p class="curl-heading">Example</p>
19065          <div class="curl-wrap">
19066            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19067  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
19068            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
19069          </div>
19070        </div>
19071      </div>
19072
19073      <div class="ep-card">
19074        <div class="ep-header">
19075          <span class="method get">GET</span>
19076          <span class="ep-path">/api/project-history</span>
19077          <span class="auth-badge protected">Protected</span>
19078          <span class="ep-desc">Project-level scan summary</span>
19079          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19080        </div>
19081        <div class="ep-body">
19082          <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>
19083          <p class="params-heading">Query Parameters</p>
19084          <table class="params">
19085            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19086            <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>
19087          </table>
19088          <details class="schema"><summary>Response schema</summary>
19089<div class="schema-block">{
19090  "scan_count":           number,
19091  "last_scan_id":         string | null,
19092  "last_scan_timestamp":  string | null,  // ISO-8601
19093  "last_scan_code_lines": number | null,
19094  "last_git_branch":      string | null,
19095  "last_git_commit":      string | null
19096}</div></details>
19097          <p class="curl-heading">Example</p>
19098          <div class="curl-wrap">
19099            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19100  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
19101            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
19102          </div>
19103        </div>
19104      </div>
19105
19106      <div class="ep-card">
19107        <div class="ep-header">
19108          <span class="method get">GET</span>
19109          <span class="ep-path">/api/metrics/submodules</span>
19110          <span class="auth-badge protected">Protected</span>
19111          <span class="ep-desc">List known git submodules across scans</span>
19112          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19113        </div>
19114        <div class="ep-body">
19115          <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>
19116          <p class="params-heading">Query Parameters</p>
19117          <table class="params">
19118            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19119            <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>
19120          </table>
19121          <details class="schema"><summary>Response schema</summary>
19122<div class="schema-block">[{
19123  "name":          string,  // submodule name
19124  "relative_path": string   // path relative to the project root
19125}]</div></details>
19126          <p class="curl-heading">Example</p>
19127          <div class="curl-wrap">
19128            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19129  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
19130            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
19131          </div>
19132        </div>
19133      </div>
19134    </div>
19135
19136    <!-- Async Run Status -->
19137    <div class="section">
19138      <h2 class="section-title">Async Run Status</h2>
19139
19140      <div class="ep-card">
19141        <div class="ep-header">
19142          <span class="method get">GET</span>
19143          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
19144          <span class="auth-badge protected">Protected</span>
19145          <span class="ep-desc">Poll scan completion</span>
19146          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19147        </div>
19148        <div class="ep-body">
19149          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
19150          <details class="schema"><summary>Response schema</summary>
19151<div class="schema-block">// Running
19152{ "state": "running",  "elapsed_secs": number }
19153
19154// Complete
19155{ "state": "complete", "run_id": string }
19156
19157// Failed
19158{ "state": "failed",   "message": string }</div></details>
19159          <p class="curl-heading">Example</p>
19160          <div class="curl-wrap">
19161            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19162  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
19163            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
19164          </div>
19165        </div>
19166      </div>
19167
19168      <div class="ep-card">
19169        <div class="ep-header">
19170          <span class="method get">GET</span>
19171          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
19172          <span class="auth-badge protected">Protected</span>
19173          <span class="ep-desc">Poll PDF generation readiness</span>
19174          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19175        </div>
19176        <div class="ep-body">
19177          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
19178          <details class="schema"><summary>Response schema</summary>
19179<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
19180          <p class="curl-heading">Example</p>
19181          <div class="curl-wrap">
19182            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19183  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
19184            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
19185          </div>
19186        </div>
19187      </div>
19188
19189      <div class="ep-card">
19190        <div class="ep-header">
19191          <span class="method post">POST</span>
19192          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
19193          <span class="auth-badge protected">Protected</span>
19194          <span class="ep-desc">Cancel a running scan</span>
19195          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19196        </div>
19197        <div class="ep-body">
19198          <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>
19199          <p class="curl-heading">Example</p>
19200          <div class="curl-wrap">
19201            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
19202  -H "Authorization: Bearer $SLOC_API_KEY" \
19203  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
19204            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
19205          </div>
19206        </div>
19207      </div>
19208    </div>
19209
19210    <!-- Scan Profiles -->
19211    <div class="section">
19212      <h2 class="section-title">Scan Profiles</h2>
19213
19214      <div class="ep-card">
19215        <div class="ep-header">
19216          <span class="method get">GET</span>
19217          <span class="ep-path">/api/scan-profiles</span>
19218          <span class="auth-badge protected">Protected</span>
19219          <span class="ep-desc">List saved scan profiles</span>
19220          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19221        </div>
19222        <div class="ep-body">
19223          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
19224          <details class="schema"><summary>Response schema</summary>
19225<div class="schema-block">{
19226  "profiles": [{
19227    "id":         string,   // UUID
19228    "name":       string,
19229    "created_at": string,   // ISO-8601
19230    "params":     object
19231  }]
19232}</div></details>
19233          <p class="curl-heading">Example</p>
19234          <div class="curl-wrap">
19235            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19236  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19237            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
19238          </div>
19239        </div>
19240      </div>
19241
19242      <div class="ep-card">
19243        <div class="ep-header">
19244          <span class="method post">POST</span>
19245          <span class="ep-path">/api/scan-profiles</span>
19246          <span class="auth-badge protected">Protected</span>
19247          <span class="ep-desc">Save a scan profile</span>
19248          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19249        </div>
19250        <div class="ep-body">
19251          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
19252          <p class="params-heading">Request Body (application/json)</p>
19253          <table class="params">
19254            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19255            <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>
19256            <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>
19257          </table>
19258          <details class="schema"><summary>Response schema</summary>
19259<div class="schema-block">{ "ok": true }</div></details>
19260          <p class="curl-heading">Example</p>
19261          <div class="curl-wrap">
19262            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
19263  -H "Authorization: Bearer $SLOC_API_KEY" \
19264  -H "Content-Type: application/json" \
19265  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
19266  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19267            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
19268          </div>
19269        </div>
19270      </div>
19271
19272      <div class="ep-card">
19273        <div class="ep-header">
19274          <span class="method delete">DELETE</span>
19275          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
19276          <span class="auth-badge protected">Protected</span>
19277          <span class="ep-desc">Delete a scan profile</span>
19278          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19279        </div>
19280        <div class="ep-body">
19281          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
19282          <p class="params-heading">Path Parameters</p>
19283          <table class="params">
19284            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19285            <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>
19286          </table>
19287          <details class="schema"><summary>Response schema</summary>
19288<div class="schema-block">{ "ok": true }</div></details>
19289          <p class="curl-heading">Example</p>
19290          <div class="curl-wrap">
19291            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
19292  -H "Authorization: Bearer $SLOC_API_KEY" \
19293  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
19294            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
19295          </div>
19296        </div>
19297      </div>
19298    </div>
19299
19300    <!-- Scheduled Scans -->
19301    <div class="section">
19302      <h2 class="section-title">Scheduled Scans</h2>
19303
19304      <div class="ep-card">
19305        <div class="ep-header">
19306          <span class="method get">GET</span>
19307          <span class="ep-path">/api/schedules</span>
19308          <span class="auth-badge protected">Protected</span>
19309          <span class="ep-desc">List configured schedules</span>
19310          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19311        </div>
19312        <div class="ep-body">
19313          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
19314          <p class="curl-heading">Example</p>
19315          <div class="curl-wrap">
19316            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19317  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19318            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
19319          </div>
19320        </div>
19321      </div>
19322
19323      <div class="ep-card">
19324        <div class="ep-header">
19325          <span class="method post">POST</span>
19326          <span class="ep-path">/api/schedules</span>
19327          <span class="auth-badge protected">Protected</span>
19328          <span class="ep-desc">Create a schedule</span>
19329          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19330        </div>
19331        <div class="ep-body">
19332          <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>
19333          <p class="curl-heading">Example</p>
19334          <div class="curl-wrap">
19335            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
19336  -H "Authorization: Bearer $SLOC_API_KEY" \
19337  -H "Content-Type: application/json" \
19338  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
19339  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19340            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
19341          </div>
19342        </div>
19343      </div>
19344
19345      <div class="ep-card">
19346        <div class="ep-header">
19347          <span class="method delete">DELETE</span>
19348          <span class="ep-path">/api/schedules</span>
19349          <span class="auth-badge protected">Protected</span>
19350          <span class="ep-desc">Delete a schedule</span>
19351          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19352        </div>
19353        <div class="ep-body">
19354          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
19355          <p class="curl-heading">Example</p>
19356          <div class="curl-wrap">
19357            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
19358  -H "Authorization: Bearer $SLOC_API_KEY" \
19359  -H "Content-Type: application/json" \
19360  -d '{"id":"&lt;schedule_id&gt;"}' \
19361  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19362            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
19363          </div>
19364        </div>
19365      </div>
19366    </div>
19367
19368    <!-- Git Browser -->
19369    <div class="section">
19370      <h2 class="section-title">Git Browser</h2>
19371
19372      <div class="ep-card">
19373        <div class="ep-header">
19374          <span class="method get">GET</span>
19375          <span class="ep-path">/api/git/refs</span>
19376          <span class="auth-badge protected">Protected</span>
19377          <span class="ep-desc">List git refs for a repository</span>
19378          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19379        </div>
19380        <div class="ep-body">
19381          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
19382          <p class="params-heading">Query Parameters</p>
19383          <table class="params">
19384            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19385            <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>
19386          </table>
19387          <p class="curl-heading">Example</p>
19388          <div class="curl-wrap">
19389            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19390  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
19391            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
19392          </div>
19393        </div>
19394      </div>
19395
19396      <div class="ep-card">
19397        <div class="ep-header">
19398          <span class="method get">GET</span>
19399          <span class="ep-path">/api/git/scan-ref</span>
19400          <span class="auth-badge protected">Protected</span>
19401          <span class="ep-desc">SLOC-scan a specific git ref</span>
19402          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19403        </div>
19404        <div class="ep-body">
19405          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
19406          <p class="params-heading">Query Parameters</p>
19407          <table class="params">
19408            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19409            <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>
19410            <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>
19411          </table>
19412          <p class="curl-heading">Example</p>
19413          <div class="curl-wrap">
19414            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19415  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
19416            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
19417          </div>
19418        </div>
19419      </div>
19420
19421      <div class="ep-card">
19422        <div class="ep-header">
19423          <span class="method get">GET</span>
19424          <span class="ep-path">/api/git/compare-refs</span>
19425          <span class="auth-badge protected">Protected</span>
19426          <span class="ep-desc">Compare SLOC across two git refs</span>
19427          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19428        </div>
19429        <div class="ep-body">
19430          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
19431          <p class="params-heading">Query Parameters</p>
19432          <table class="params">
19433            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19434            <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>
19435            <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>
19436            <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>
19437          </table>
19438          <p class="curl-heading">Example</p>
19439          <div class="curl-wrap">
19440            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19441  "<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>
19442            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
19443          </div>
19444        </div>
19445      </div>
19446    </div>
19447
19448    <!-- Webhooks -->
19449    <div class="section">
19450      <h2 class="section-title">Webhooks</h2>
19451      <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>
19452
19453      <div class="ep-card">
19454        <div class="ep-header">
19455          <span class="method post">POST</span>
19456          <span class="ep-path">/webhooks/github</span>
19457          <span class="auth-badge hmac">HMAC</span>
19458          <span class="ep-desc">GitHub push event receiver</span>
19459          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19460        </div>
19461        <div class="ep-body">
19462          <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>
19463          <p class="params-heading">Required Headers</p>
19464          <table class="params">
19465            <tr><th>Header</th><th>Value</th></tr>
19466            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
19467            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
19468            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19469          </table>
19470        </div>
19471      </div>
19472
19473      <div class="ep-card">
19474        <div class="ep-header">
19475          <span class="method post">POST</span>
19476          <span class="ep-path">/webhooks/gitlab</span>
19477          <span class="auth-badge hmac">HMAC</span>
19478          <span class="ep-desc">GitLab push event receiver</span>
19479          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19480        </div>
19481        <div class="ep-body">
19482          <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>
19483          <p class="params-heading">Required Headers</p>
19484          <table class="params">
19485            <tr><th>Header</th><th>Value</th></tr>
19486            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
19487            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
19488            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19489          </table>
19490        </div>
19491      </div>
19492
19493      <div class="ep-card">
19494        <div class="ep-header">
19495          <span class="method post">POST</span>
19496          <span class="ep-path">/webhooks/bitbucket</span>
19497          <span class="auth-badge hmac">HMAC</span>
19498          <span class="ep-desc">Bitbucket push event receiver</span>
19499          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19500        </div>
19501        <div class="ep-body">
19502          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
19503          <p class="params-heading">Required Headers</p>
19504          <table class="params">
19505            <tr><th>Header</th><th>Value</th></tr>
19506            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
19507            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19508          </table>
19509        </div>
19510      </div>
19511    </div>
19512
19513    <!-- Config -->
19514    <div class="section">
19515      <h2 class="section-title">Config Import / Export</h2>
19516
19517      <div class="ep-card">
19518        <div class="ep-header">
19519          <span class="method get">GET</span>
19520          <span class="ep-path">/export-config</span>
19521          <span class="auth-badge protected">Protected</span>
19522          <span class="ep-desc">Export server configuration as JSON</span>
19523          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19524        </div>
19525        <div class="ep-body">
19526          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
19527          <p class="curl-heading">Example</p>
19528          <div class="curl-wrap">
19529            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19530  -o config.json \
19531  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
19532            <button class="curl-copy-btn" data-target="c-export">Copy</button>
19533          </div>
19534        </div>
19535      </div>
19536
19537      <div class="ep-card">
19538        <div class="ep-header">
19539          <span class="method post">POST</span>
19540          <span class="ep-path">/import-config</span>
19541          <span class="auth-badge protected">Protected</span>
19542          <span class="ep-desc">Import server configuration</span>
19543          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19544        </div>
19545        <div class="ep-body">
19546          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
19547          <p class="curl-heading">Example</p>
19548          <div class="curl-wrap">
19549            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
19550  -H "Authorization: Bearer $SLOC_API_KEY" \
19551  -H "Content-Type: application/json" \
19552  -d @config.json \
19553  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
19554            <button class="curl-copy-btn" data-target="c-import">Copy</button>
19555          </div>
19556        </div>
19557      </div>
19558    </div>
19559
19560    <!-- CI Ingest -->
19561    <div class="section">
19562      <h2 class="section-title">CI Ingest</h2>
19563
19564      <div class="ep-card">
19565        <div class="ep-header">
19566          <span class="method post">POST</span>
19567          <span class="ep-path">/api/ingest</span>
19568          <span class="auth-badge protected">Protected</span>
19569          <span class="ep-desc">Push a pre-computed scan result from CI</span>
19570          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19571        </div>
19572        <div class="ep-body">
19573          <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>
19574          <p class="params-heading">Query Parameters</p>
19575          <table class="params">
19576            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19577            <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>
19578          </table>
19579          <p class="params-heading">Request Body (application/json)</p>
19580          <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>
19581          <details class="schema"><summary>Response schema</summary>
19582<div class="schema-block">// 201 Created
19583{
19584  "run_id":   string,  // UUID of the ingested run
19585  "view_url": string   // relative URL to the report page
19586}</div></details>
19587          <p class="curl-heading">Example</p>
19588          <div class="curl-wrap">
19589            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
19590  -H "Authorization: Bearer $SLOC_API_KEY" \
19591  -H "Content-Type: application/json" \
19592  -d @result.json \
19593  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
19594            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
19595          </div>
19596        </div>
19597      </div>
19598    </div>
19599
19600    <!-- Artifact Download -->
19601    <div class="section">
19602      <h2 class="section-title">Artifact Download</h2>
19603
19604      <div class="ep-card">
19605        <div class="ep-header">
19606          <span class="method get">GET</span>
19607          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
19608          <span class="auth-badge protected">Protected</span>
19609          <span class="ep-desc">Download or view a scan artifact</span>
19610          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19611        </div>
19612        <div class="ep-body">
19613          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
19614          <p class="params-heading">Path Parameters</p>
19615          <table class="params">
19616            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19617            <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>
19618            <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>
19619          </table>
19620          <p class="params-heading">Query Parameters</p>
19621          <table class="params">
19622            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19623            <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>
19624          </table>
19625          <p class="curl-heading">Example — download JSON result</p>
19626          <div class="curl-wrap">
19627            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19628  -o result.json \
19629  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
19630            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
19631          </div>
19632        </div>
19633      </div>
19634    </div>
19635
19636    <!-- Embed Widget -->
19637    <div class="section">
19638      <h2 class="section-title">Embed Widget</h2>
19639
19640      <div class="ep-card">
19641        <div class="ep-header">
19642          <span class="method get">GET</span>
19643          <span class="ep-path">/embed/summary</span>
19644          <span class="auth-badge protected">Protected</span>
19645          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
19646          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19647        </div>
19648        <div class="ep-body">
19649          <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>
19650          <p class="params-heading">Query Parameters</p>
19651          <table class="params">
19652            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19653            <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>
19654            <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>
19655          </table>
19656          <p class="curl-heading">Example</p>
19657          <div class="curl-wrap">
19658            <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"
19659        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
19660            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
19661          </div>
19662        </div>
19663      </div>
19664    </div>
19665
19666    <!-- Confluence Integration -->
19667    <div class="section">
19668      <h2 class="section-title">Confluence Integration</h2>
19669
19670      <div class="ep-card">
19671        <div class="ep-header">
19672          <span class="method get">GET</span>
19673          <span class="ep-path">/api/confluence/config</span>
19674          <span class="auth-badge protected">Protected</span>
19675          <span class="ep-desc">Get current Confluence configuration</span>
19676          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19677        </div>
19678        <div class="ep-body">
19679          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
19680          <details class="schema"><summary>Response schema</summary>
19681<div class="schema-block">{
19682  "configured":     boolean,
19683  "tier":           "cloud" | "server",
19684  "base_url":       string,
19685  "username":       string,
19686  "api_token_set":  boolean,
19687  "space_key":      string,
19688  "parent_page_id": string | null,
19689  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
19690}</div></details>
19691          <p class="curl-heading">Example</p>
19692          <div class="curl-wrap">
19693            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19694  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19695            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
19696          </div>
19697        </div>
19698      </div>
19699
19700      <div class="ep-card">
19701        <div class="ep-header">
19702          <span class="method post">POST</span>
19703          <span class="ep-path">/api/confluence/config</span>
19704          <span class="auth-badge protected">Protected</span>
19705          <span class="ep-desc">Save Confluence configuration</span>
19706          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19707        </div>
19708        <div class="ep-body">
19709          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
19710          <p class="params-heading">Request Body (application/json)</p>
19711          <table class="params">
19712            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19713            <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>
19714            <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>
19715            <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>
19716            <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>
19717            <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>
19718            <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>
19719            <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>
19720          </table>
19721          <details class="schema"><summary>Response schema</summary>
19722<div class="schema-block">{ "ok": true }</div></details>
19723          <p class="curl-heading">Example</p>
19724          <div class="curl-wrap">
19725            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
19726  -H "Authorization: Bearer $SLOC_API_KEY" \
19727  -H "Content-Type: application/json" \
19728  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
19729  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19730            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
19731          </div>
19732        </div>
19733      </div>
19734
19735      <div class="ep-card">
19736        <div class="ep-header">
19737          <span class="method post">POST</span>
19738          <span class="ep-path">/api/confluence/test</span>
19739          <span class="auth-badge protected">Protected</span>
19740          <span class="ep-desc">Test Confluence connection</span>
19741          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19742        </div>
19743        <div class="ep-body">
19744          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
19745          <details class="schema"><summary>Response schema</summary>
19746<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
19747          <p class="curl-heading">Example</p>
19748          <div class="curl-wrap">
19749            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
19750  -H "Authorization: Bearer $SLOC_API_KEY" \
19751  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
19752            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
19753          </div>
19754        </div>
19755      </div>
19756
19757      <div class="ep-card">
19758        <div class="ep-header">
19759          <span class="method post">POST</span>
19760          <span class="ep-path">/api/confluence/post</span>
19761          <span class="auth-badge protected">Protected</span>
19762          <span class="ep-desc">Publish a scan report to Confluence</span>
19763          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19764        </div>
19765        <div class="ep-body">
19766          <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>
19767          <p class="params-heading">Request Body (application/json)</p>
19768          <table class="params">
19769            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19770            <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>
19771            <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>
19772            <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>
19773          </table>
19774          <details class="schema"><summary>Response schema</summary>
19775<div class="schema-block">// 200 OK
19776{ "ok": true, "page_id": string }
19777
19778// 400 / 502 on error
19779{ "ok": false, "error": string }</div></details>
19780          <p class="curl-heading">Example</p>
19781          <div class="curl-wrap">
19782            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
19783  -H "Authorization: Bearer $SLOC_API_KEY" \
19784  -H "Content-Type: application/json" \
19785  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
19786  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
19787            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
19788          </div>
19789        </div>
19790      </div>
19791
19792      <div class="ep-card">
19793        <div class="ep-header">
19794          <span class="method get">GET</span>
19795          <span class="ep-path">/api/confluence/wiki-markup</span>
19796          <span class="auth-badge protected">Protected</span>
19797          <span class="ep-desc">Get Confluence wiki markup for a run</span>
19798          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19799        </div>
19800        <div class="ep-body">
19801          <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>
19802          <p class="params-heading">Query Parameters</p>
19803          <table class="params">
19804            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19805            <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>
19806          </table>
19807          <p class="curl-heading">Example</p>
19808          <div class="curl-wrap">
19809            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19810  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
19811            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
19812          </div>
19813        </div>
19814      </div>
19815    </div>
19816
19817    <!-- Authentication -->
19818    <div class="section">
19819      <h2 class="section-title">Authentication</h2>
19820      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
19821
19822      <div class="ep-card">
19823        <div class="ep-header">
19824          <span class="method get">GET</span>
19825          <span class="ep-path">/auth/login</span>
19826          <span class="auth-badge public">Public</span>
19827          <span class="ep-desc">Login page</span>
19828          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19829        </div>
19830        <div class="ep-body">
19831          <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>
19832          <p class="params-heading">Query Parameters</p>
19833          <table class="params">
19834            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19835            <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>
19836            <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>
19837          </table>
19838        </div>
19839      </div>
19840
19841      <div class="ep-card">
19842        <div class="ep-header">
19843          <span class="method post">POST</span>
19844          <span class="ep-path">/auth/login</span>
19845          <span class="auth-badge public">Public</span>
19846          <span class="ep-desc">Submit credentials and get a session cookie</span>
19847          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19848        </div>
19849        <div class="ep-body">
19850          <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>
19851          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
19852          <table class="params">
19853            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19854            <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>
19855            <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>
19856          </table>
19857          <p class="curl-heading">Example</p>
19858          <div class="curl-wrap">
19859            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
19860  -d "key=$SLOC_API_KEY&amp;next=/" \
19861  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
19862            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
19863          </div>
19864        </div>
19865      </div>
19866    </div>
19867
19868    <!-- Coverage Suggestion -->
19869    <div class="section">
19870      <h2 class="section-title">Coverage Suggestion</h2>
19871
19872      <div class="ep-card">
19873        <div class="ep-header">
19874          <span class="method get">GET</span>
19875          <span class="ep-path">/api/suggest-coverage</span>
19876          <span class="auth-badge protected">Protected</span>
19877          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
19878          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19879        </div>
19880        <div class="ep-body">
19881          <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>
19882          <p class="params-heading">Query Parameters</p>
19883          <table class="params">
19884            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19885            <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>
19886          </table>
19887          <details class="schema"><summary>Response schema</summary>
19888<div class="schema-block">{
19889  "found": string | null,  // absolute path to the coverage file, if detected
19890  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
19891  "hint":  string | null   // shell command to generate coverage if not found
19892}</div></details>
19893          <p class="curl-heading">Example</p>
19894          <div class="curl-wrap">
19895            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19896  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
19897            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
19898          </div>
19899        </div>
19900      </div>
19901    </div>
19902
19903  </div>
19904
19905  <footer class="site-footer">
19906    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
19907    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19908    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19909    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19910    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19911  </footer>
19912
19913  <script nonce="{{ csp_nonce }}">
19914    (function () {
19915      var base = window.location.origin;
19916      document.getElementById('base-url').textContent = base;
19917      document.querySelectorAll('.base-url-slot').forEach(function (el) {
19918        el.textContent = base;
19919      });
19920
19921      document.querySelectorAll('.ep-header').forEach(function (hdr) {
19922        hdr.addEventListener('click', function () {
19923          hdr.closest('.ep-card').classList.toggle('open');
19924        });
19925      });
19926
19927      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
19928        btn.addEventListener('click', function () {
19929          var targetId = btn.dataset.target;
19930          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
19931          if (!pre) return;
19932          navigator.clipboard.writeText(pre.textContent).then(function () {
19933            btn.textContent = 'Copied!';
19934            btn.classList.add('copied');
19935            setTimeout(function () {
19936              btn.textContent = 'Copy';
19937              btn.classList.remove('copied');
19938            }, 2000);
19939          });
19940        });
19941      });
19942
19943      var storageKey = 'oxide-sloc-theme';
19944      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
19945      var themeBtn = document.getElementById('theme-toggle');
19946      if (themeBtn) {
19947        themeBtn.addEventListener('click', function () {
19948          var dark = document.body.classList.toggle('dark-theme');
19949          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
19950        });
19951      }
19952      (function() {
19953        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'}];
19954        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);});}
19955        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19956        var btn=document.getElementById('settings-btn');if(!btn)return;
19957        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19958        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>';
19959        document.body.appendChild(m);
19960        var g=document.getElementById('scheme-grid');
19961        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);});
19962        var cl=document.getElementById('settings-close');
19963        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);
19964        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');});
19965        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19966        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19967      })();
19968      (function randomizeWatermarks() {
19969        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19970        if (!wms.length) return;
19971        var placed = [];
19972        function tooClose(top, left) {
19973          for (var i = 0; i < placed.length; i++) {
19974            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19975            if (dt < 16 && dl < 12) return true;
19976          }
19977          return false;
19978        }
19979        function pick(leftBand) {
19980          for (var attempt = 0; attempt < 50; attempt++) {
19981            var top = Math.random() * 88 + 2;
19982            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19983            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19984          }
19985          var top = Math.random() * 88 + 2;
19986          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19987          placed.push([top, left]); return [top, left];
19988        }
19989        var half = Math.floor(wms.length / 2);
19990        wms.forEach(function (img, i) {
19991          var pos = pick(i < half);
19992          var size = Math.floor(Math.random() * 100 + 120);
19993          var rot = (Math.random() * 360).toFixed(1);
19994          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19995          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;
19996        });
19997      })();
19998      (function spawnCodeParticles() {
19999        var container = document.getElementById('code-particles');
20000        if (!container) return;
20001        var snippets = [
20002          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
20003          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
20004          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
20005          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
20006          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
20007        ];
20008        var count = 38;
20009        for (var i = 0; i < count; i++) {
20010          (function(idx) {
20011            var el = document.createElement('span');
20012            el.className = 'code-particle';
20013            el.textContent = snippets[idx % snippets.length];
20014            var left = Math.random() * 94 + 2;
20015            var top = Math.random() * 88 + 6;
20016            var dur = (Math.random() * 10 + 9).toFixed(1);
20017            var delay = (Math.random() * 18).toFixed(1);
20018            var rot = (Math.random() * 26 - 13).toFixed(1);
20019            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20020            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
20021            container.appendChild(el);
20022          })(i);
20023        }
20024      })();
20025    }());
20026  </script>
20027</body>
20028</html>
20029"##,
20030    ext = "html"
20031)]
20032struct ApiDocsTemplate {
20033    has_api_key: bool,
20034    csp_nonce: String,
20035    version: &'static str,
20036}