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    scan_config_path: Option<PathBuf>,
414    report_title: String,
415    result_context: RunResultContext,
416}
417
418#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
419fn build_router(state: AppState) -> Router {
420    // NOSONAR(rust:S3776)
421    let protected = Router::new()
422        .route("/", get(splash))
423        .route("/scan-setup", get(scan_setup_handler))
424        .route("/scan", get(index))
425        .route("/analyze", post(analyze_handler))
426        .route("/preview", get(preview_handler))
427        .route("/api/suggest-coverage", get(api_suggest_coverage))
428        .route("/pick-directory", get(pick_directory_handler))
429        .route("/open-path", get(open_path_handler))
430        .route("/pick-file", get(pick_file_handler))
431        .route("/locate-report", post(locate_report_handler))
432        .route("/locate-reports-dir", post(locate_reports_dir_handler))
433        .route("/relocate-scan", post(relocate_scan_handler))
434        .route("/watched-dirs/add", post(add_watched_dir_handler))
435        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
436        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
437        .route("/view-reports", get(history_handler))
438        .route("/compare-scans", get(compare_select_handler))
439        .route("/compare", get(compare_handler))
440        .route("/images/{folder}/{file}", get(image_handler))
441        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
442        .route("/api/metrics/latest", get(api_metrics_latest_handler))
443        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
444        .route("/api/metrics/history", get(api_metrics_history_handler))
445        .route(
446            "/api/metrics/submodules",
447            get(api_metrics_submodules_handler),
448        )
449        .route("/api/ingest", post(api_ingest_handler))
450        .route("/api/project-history", get(project_history_handler))
451        .route("/trend-reports", get(trend_report_handler))
452        .route("/test-metrics", get(test_metrics_handler))
453        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
454        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
455        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
456        .route("/runs/result/{run_id}", get(async_run_result_handler))
457        .route("/embed/summary", get(embed_handler))
458        // ── Git browser ────────────────────────────────────────────────────────
459        .route("/git-browser", get(git_browser::git_browser_handler))
460        .route("/api/git/refs", get(git_browser::api_list_refs))
461        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
462        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
463        // ── Config export / import ─────────────────────────────────────────────
464        .route("/export-config", get(export_config_handler))
465        .route("/import-config", post(import_config_handler))
466        // ── Scan profiles ──────────────────────────────────────────────────────
467        .route("/api/scan-profiles", get(api_list_scan_profiles))
468        .route("/api/scan-profiles", post(api_save_scan_profile))
469        .route(
470            "/api/scan-profiles/{id}",
471            axum::routing::delete(api_delete_scan_profile),
472        )
473        // ── Integrations (webhooks + Confluence) ──────────────────────────────
474        .route("/integrations", get(integrations::integrations_handler))
475        .route(
476            "/webhook-setup",
477            get(|| async { axum::response::Redirect::permanent("/integrations#webhooks") }),
478        )
479        .route(
480            "/confluence-setup",
481            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
482        )
483        .route("/api/schedules", get(git_webhook::api_list_schedules))
484        .route("/api/schedules", post(git_webhook::api_create_schedule))
485        .route(
486            "/api/schedules",
487            axum::routing::delete(git_webhook::api_delete_schedule),
488        )
489        .route(
490            "/api/confluence/config",
491            get(confluence::api_get_confluence_config),
492        )
493        .route(
494            "/api/confluence/config",
495            post(confluence::api_save_confluence_config),
496        )
497        .route(
498            "/api/confluence/test",
499            post(confluence::api_test_confluence),
500        )
501        .route(
502            "/api/confluence/post",
503            post(confluence::api_post_to_confluence),
504        )
505        .route(
506            "/api/confluence/wiki-markup",
507            get(confluence::api_wiki_markup),
508        )
509        // ── REST API reference page ────────────────────────────────────────────
510        .route("/api-docs", get(api_docs_handler))
511        .route_layer(middleware::from_fn_with_state(
512            state.clone(),
513            require_api_key,
514        ));
515
516    protected
517        .route("/healthz", get(healthz))
518        .route("/badge/{metric}", get(badge_handler))
519        .route("/static/chart.js", get(chart_js_handler))
520        .route("/auth/login", get(auth_login_get))
521        .route("/auth/login", post(auth_login_post))
522        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
523        .route("/webhooks/github", post(git_webhook::handle_github_webhook))
524        .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
525        .route(
526            "/webhooks/bitbucket",
527            post(git_webhook::handle_bitbucket_webhook),
528        )
529        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
530        .layer(middleware::from_fn_with_state(
531            state.clone(),
532            add_security_headers,
533        ))
534        .layer(build_cors_layer(state.server_mode))
535        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
536        .with_state(state)
537}
538
539/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
540pub fn make_test_router() -> Router {
541    let tmp = std::env::temp_dir().join("sloc_test");
542    let state = AppState {
543        base_config: AppConfig::default(),
544        artifacts: Arc::new(Mutex::new(HashMap::new())),
545        async_runs: Arc::new(Mutex::new(HashMap::new())),
546        registry: Arc::new(Mutex::new(ScanRegistry::default())),
547        registry_path: tmp.join("registry.json"),
548        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
549        server_mode: false,
550        tls_enabled: false,
551        api_keys: vec![],
552        rate_limiter: Arc::new(IpRateLimiter::new(
553            Duration::from_mins(1),
554            600,
555            10,
556            Duration::from_hours(1),
557        )),
558        trust_proxy: false,
559        git_clones_dir: tmp.join("git-clones"),
560        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
561        schedules_path: tmp.join("schedules.json"),
562        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
563        scan_profiles_path: tmp.join("scan_profiles.json"),
564        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
565        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
566        confluence_path: tmp.join("confluence_config.json"),
567        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
568        watched_dirs_path: tmp.join("watched_dirs.json"),
569    };
570    build_router(state)
571}
572
573/// # Errors
574///
575/// Returns an error if the server fails to bind to the configured address or
576/// if the TLS configuration cannot be loaded.
577///
578/// # Panics
579///
580/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
581// The function coordinates TLS setup, router construction, and async listener setup in one
582// place; splitting it further would require passing many state values across function boundaries.
583#[allow(clippy::too_many_lines)]
584pub async fn serve(config: AppConfig) -> Result<()> {
585    // NOSONAR(rust:S3776)
586    let bind_address = config.web.bind_address.clone();
587    let server_mode = config.web.server_mode;
588    let output_root = resolve_output_root(None);
589    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
590    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
591        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
592    let mut registry = ScanRegistry::load(&registry_path);
593    registry.prune_stale();
594    let _ = registry.save(&registry_path);
595
596    let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
597        .or_else(|_| std::env::var("SLOC_API_KEY"))
598        .unwrap_or_default()
599        .split(',')
600        .map(str::trim)
601        .filter(|s| !s.is_empty())
602        .map(|s| secrecy::Secret::new(s.to_owned()))
603        .collect();
604    if server_mode && api_keys.is_empty() {
605        println!(
606            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
607             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
608        );
609    }
610
611    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
612    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
613    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
614    if server_mode && !tls_enabled {
615        println!(
616            "WARNING: TLS is not configured. Traffic is cleartext. \
617             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
618             or terminate TLS at a reverse proxy (nginx, caddy)."
619        );
620    }
621    if server_mode {
622        println!(
623            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
624             to restrict cross-origin access (comma-separated)."
625        );
626    }
627    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
628    if trust_proxy {
629        println!(
630            "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
631             Only set this when oxide-sloc is behind a trusted reverse proxy."
632        );
633    }
634
635    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
636        .ok()
637        .and_then(|v| v.parse::<u32>().ok())
638        .unwrap_or(10);
639    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
640        .ok()
641        .and_then(|v| v.parse::<u64>().ok())
642        .unwrap_or(3600);
643    // 600 req/min per IP across all routes (10/sec — suits local/air-gapped use).
644    let rate_limiter = Arc::new(IpRateLimiter::new(
645        Duration::from_mins(1),
646        600,
647        auth_lockout_threshold,
648        Duration::from_secs(auth_lockout_secs),
649    ));
650    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
651
652    let git_clones_dir = resolve_git_clones_dir(&output_root);
653    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
654        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
655    let schedules = ScheduleStore::load(&schedules_path);
656    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
657        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
658    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
659    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
660        |_| output_root.join("confluence_config.json"),
661        PathBuf::from,
662    );
663    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
664    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
665        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
666    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
667
668    let state = AppState {
669        base_config: config,
670        artifacts: Arc::new(Mutex::new(HashMap::new())),
671        async_runs: Arc::new(Mutex::new(HashMap::new())),
672        registry: Arc::new(Mutex::new(registry)),
673        registry_path,
674        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
675        server_mode,
676        tls_enabled,
677        api_keys,
678        rate_limiter,
679        trust_proxy,
680        git_clones_dir,
681        schedules: Arc::new(Mutex::new(schedules)),
682        schedules_path,
683        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
684        scan_profiles_path,
685        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
686        confluence: Arc::new(Mutex::new(confluence)),
687        confluence_path,
688        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
689        watched_dirs_path,
690    };
691
692    restart_poll_schedules(&state).await;
693
694    let app = build_router(state.clone());
695
696    // Try the configured port first, then step up through a few alternatives.
697    // On Windows, a killed process can leave its LISTEN socket as an unkillable
698    // kernel zombie (visible in netstat but owned by no living process).  Rather
699    // than failing, we auto-select the next free port and tell the user.
700    let preferred: SocketAddr = bind_address
701        .parse()
702        .with_context(|| format!("invalid bind address: {bind_address}"))?;
703    let (listener, addr) = {
704        let candidates = (0u16..=9).map(|offset| {
705            let mut a = preferred;
706            a.set_port(preferred.port().saturating_add(offset));
707            a
708        });
709        let mut found = None;
710        for candidate in candidates {
711            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
712                found = Some((l, candidate));
713                break;
714            }
715        }
716        found.ok_or_else(|| {
717            anyhow::anyhow!(
718                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
719                bind_address,
720                preferred.port(),
721                preferred.port().saturating_add(9)
722            )
723        })?
724    };
725    if addr != preferred {
726        eprintln!(
727            "NOTE: port {} is blocked by a system socket (Windows zombie); \
728             using {} instead.",
729            preferred.port(),
730            addr.port()
731        );
732    }
733
734    if tls_enabled {
735        let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
736        let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
737        let tls_config = build_tls_config(&cert_path, &key_path)
738            .context("failed to load TLS certificate/key")?;
739        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
740
741        let url = format!("https://{addr}/");
742        println!("OxideSLOC server running at {url} (TLS)");
743        println!("Use Ctrl+C to stop.");
744
745        return serve_tls(listener, app, acceptor, server_mode).await;
746    }
747
748    let url = format!("http://{addr}/");
749    log_startup_url(&url, server_mode);
750
751    axum::serve(
752        listener,
753        app.into_make_service_with_connect_info::<SocketAddr>(),
754    )
755    .with_graceful_shutdown(shutdown_signal(server_mode))
756    .await
757    .context("web server terminated unexpectedly")
758}
759
760/// Discover the primary non-loopback IPv4 address by asking the OS which
761/// outbound interface it would use to reach a public address.  No packets are
762/// sent — the UDP socket is only used to query the routing table.
763fn primary_lan_ip() -> Option<String> {
764    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
765    socket.connect("8.8.8.8:80").ok()?;
766    let addr = socket.local_addr().ok()?;
767    let ip = addr.ip();
768    if ip.is_loopback() {
769        return None;
770    }
771    Some(ip.to_string())
772}
773
774/// Print the startup URL and, in local mode, open the browser and schedule it.
775fn log_startup_url(url: &str, server_mode: bool) {
776    if server_mode {
777        println!("OxideSLOC server running at {url}");
778        println!("Use Ctrl+C to stop.");
779    } else {
780        println!("OxideSLOC local web UI running at {url}");
781        println!("Press Ctrl+C to stop the server.");
782        let open_url = url.to_owned();
783        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
784    }
785}
786
787/// Open the given URL in the default system browser.
788fn open_browser_tab(url: &str) {
789    #[cfg(target_os = "windows")]
790    let _ = std::process::Command::new("cmd")
791        .args(["/c", "start", "", url])
792        .stdout(Stdio::null())
793        .stderr(Stdio::null())
794        .spawn();
795    #[cfg(target_os = "macos")]
796    let _ = std::process::Command::new("open")
797        .arg(url)
798        .stdout(Stdio::null())
799        .stderr(Stdio::null())
800        .spawn();
801    #[cfg(target_os = "linux")]
802    let _ = std::process::Command::new("xdg-open")
803        .arg(url)
804        .stdout(Stdio::null())
805        .stderr(Stdio::null())
806        .spawn();
807}
808
809/// Graceful-shutdown future: resolves on Ctrl-C.
810async fn shutdown_signal(server_mode: bool) {
811    if tokio::signal::ctrl_c().await.is_ok() {
812        println!();
813        if server_mode {
814            println!("Shutting down OxideSLOC server...");
815        } else {
816            println!("Shutting down OxideSLOC local web UI...");
817        }
818        println!("Server stopped cleanly.");
819    }
820}
821
822/// Load a rustls `ServerConfig` from PEM certificate and key files.
823fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
824    use rustls_pemfile::{certs, private_key};
825    use std::io::BufReader;
826
827    let cert_bytes =
828        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
829    let key_bytes =
830        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
831
832    let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
833        .collect::<std::result::Result<_, _>>()
834        .context("failed to parse TLS certificates")?;
835
836    let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
837        .context("failed to parse TLS private key")?
838        .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
839
840    rustls::ServerConfig::builder()
841        .with_no_client_auth()
842        .with_single_cert(cert_chain, key)
843        .context("failed to build TLS server config")
844}
845
846/// Accept loop with TLS termination using tokio-rustls + hyper-util.
847async fn serve_tls(
848    listener: tokio::net::TcpListener,
849    app: Router,
850    acceptor: tokio_rustls::TlsAcceptor,
851    server_mode: bool,
852) -> Result<()> {
853    use hyper_util::rt::{TokioExecutor, TokioIo};
854    use hyper_util::server::conn::auto::Builder as ConnBuilder;
855    use hyper_util::service::TowerToHyperService;
856    use tower::{Service, ServiceExt};
857
858    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
859
860    loop {
861        tokio::select! {
862            biased;
863            _ = tokio::signal::ctrl_c() => {
864                println!();
865                if server_mode {
866                    println!("Shutting down OxideSLOC server...");
867                } else {
868                    println!("Shutting down OxideSLOC local web UI...");
869                }
870                println!("Server stopped cleanly.");
871                return Ok(());
872            }
873            result = listener.accept() => {
874                let (tcp, peer_addr) = result.context("TLS accept failed")?;
875                let acceptor = acceptor.clone();
876                let mut factory = make_svc.clone();
877
878                tokio::spawn(async move {
879                    let tls = match acceptor.accept(tcp).await {
880                        Ok(s) => s,
881                        Err(e) => {
882                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
883                            return;
884                        }
885                    };
886                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
887                        Ok(f) => match Service::call(f, peer_addr).await {
888                            Ok(s) => s,
889                            Err(_) => return,
890                        },
891                        Err(_) => return,
892                    };
893                    let io = TokioIo::new(tls);
894                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
895                        .serve_connection(io, TowerToHyperService::new(svc))
896                        .await
897                    {
898                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
899                    }
900                });
901            }
902        }
903    }
904}
905
906#[allow(clippy::too_many_lines)] // middleware with multi-path auth logic; extraction is impractical
907async fn require_api_key(
908    // NOSONAR(rust:S3776)
909    State(state): State<AppState>,
910    req: Request<Body>,
911    next: Next,
912) -> Response {
913    if state.api_keys.is_empty() {
914        return next.run(req).await;
915    }
916
917    let keys = &state.api_keys;
918    let peer_ip = req
919        .extensions()
920        .get::<axum::extract::ConnectInfo<SocketAddr>>()
921        .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
922
923    // Collect credentials from all three sources: Bearer header, X-API-Key, session cookie.
924    let auth_header = req
925        .headers()
926        .get(header::AUTHORIZATION)
927        .and_then(|v| v.to_str().ok())
928        .and_then(|v| v.strip_prefix("Bearer "))
929        .map(str::to_owned);
930    let x_api_key = req
931        .headers()
932        .get("X-API-Key")
933        .and_then(|v| v.to_str().ok())
934        .map(str::to_owned);
935    let session_cookie = req
936        .headers()
937        .get(header::COOKIE)
938        .and_then(|v| v.to_str().ok())
939        .and_then(extract_session_cookie)
940        .map(str::to_owned);
941
942    let session_valid = session_cookie.as_deref().is_some_and(|tok| {
943        let now = Instant::now();
944        let mut sessions = state
945            .sessions
946            .lock()
947            .unwrap_or_else(std::sync::PoisonError::into_inner);
948        if let Some(&expiry) = sessions.get(tok) {
949            if now < expiry {
950                return true;
951            }
952            sessions.remove(tok);
953        }
954        false
955    });
956
957    let any_credential_provided =
958        auth_header.is_some() || x_api_key.is_some() || session_cookie.is_some();
959
960    let valid = session_valid
961        || [&auth_header, &x_api_key]
962            .iter()
963            .filter_map(|o| o.as_deref())
964            .any(|k| {
965                keys.iter().any(|expected| {
966                    use secrecy::ExposeSecret;
967                    ct_eq(k, expected.expose_secret())
968                })
969            });
970
971    if valid {
972        return next.run(req).await;
973    }
974
975    if state.rate_limiter.is_auth_locked_out(peer_ip) {
976        tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
977            "Authentication locked out after repeated failures");
978        let remaining = state.rate_limiter.auth_lockout_remaining_secs(peer_ip);
979        let retry_after = HeaderValue::from_str(&remaining.to_string())
980            .unwrap_or(HeaderValue::from_static("3600"));
981        if is_browser_request(&req) {
982            let minutes = remaining.div_ceil(60).max(1);
983            let s = if minutes == 1 { "" } else { "s" };
984            let body = format!(
985                r#"<!doctype html><html><head><meta charset="utf-8">
986<title>Locked Out — OxideSLOC</title>
987<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#2f241c}}
988h1{{color:#b85d33}}p{{line-height:1.6}}code{{background:#f3e9e0;padding:2px 6px;border-radius:4px}}</style>
989</head><body>
990<h1>Too many failed sign-in attempts</h1>
991<p>Access from your IP is temporarily locked. Lockout expires in approximately
992<strong>{minutes} minute{s}</strong>.</p>
993<p>To clear immediately, restart the server.</p>
994<p>For trusted LAN testing, leave <code>SLOC_API_KEY</code> unset, or raise the
995threshold via <code>SLOC_AUTH_LOCKOUT_FAILS</code> / <code>SLOC_AUTH_LOCKOUT_SECS</code>.</p>
996</body></html>"#
997            );
998            let mut resp = (StatusCode::TOO_MANY_REQUESTS, Html(body)).into_response();
999            resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
1000            return resp;
1001        }
1002        let mut resp = (
1003            StatusCode::TOO_MANY_REQUESTS,
1004            format!("429 Too Many Requests — locked out, retry in {remaining}s\n"),
1005        )
1006            .into_response();
1007        resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
1008        return resp;
1009    }
1010
1011    if any_credential_provided {
1012        // A credential was supplied but didn't match — record the failure.
1013        state.rate_limiter.record_auth_failure(peer_ip);
1014        let path = req.uri().path().to_owned();
1015        tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
1016            "API key authentication failed");
1017        return (
1018            StatusCode::UNAUTHORIZED,
1019            [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
1020            "401 Unauthorized\n",
1021        )
1022            .into_response();
1023    }
1024
1025    // No credential supplied at all.  Redirect browsers to the login form; return
1026    // a plain 401 for API clients (without recording a failure — unauthenticated
1027    // browser page loads should not burn the lockout counter).
1028    if is_browser_request(&req) {
1029        let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1030        let login_url = format!("/auth/login?next={}", urlencode_path(next_path));
1031        let location = HeaderValue::from_str(&login_url)
1032            .unwrap_or_else(|_| HeaderValue::from_static("/auth/login"));
1033        let mut resp = StatusCode::FOUND.into_response();
1034        resp.headers_mut().insert(header::LOCATION, location);
1035        return resp;
1036    }
1037
1038    (
1039        StatusCode::UNAUTHORIZED,
1040        [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
1041        "401 Unauthorized\n",
1042    )
1043        .into_response()
1044}
1045
1046fn ct_eq(a: &str, b: &str) -> bool {
1047    use subtle::ConstantTimeEq;
1048    a.as_bytes().ct_eq(b.as_bytes()).into()
1049}
1050
1051fn extract_session_cookie(cookie_header: &str) -> Option<&str> {
1052    cookie_header.split(';').find_map(|pair| {
1053        let pair = pair.trim();
1054        let (k, v) = pair.split_once('=')?;
1055        if k.trim() == "sloc_session" {
1056            Some(v.trim())
1057        } else {
1058            None
1059        }
1060    })
1061}
1062
1063fn is_browser_request(req: &Request<Body>) -> bool {
1064    req.headers()
1065        .get(header::ACCEPT)
1066        .and_then(|v| v.to_str().ok())
1067        .is_some_and(|a| a.contains("text/html"))
1068}
1069
1070fn urlencode_path(s: &str) -> String {
1071    let mut out = String::with_capacity(s.len());
1072    for b in s.bytes() {
1073        match b {
1074            b'A'..=b'Z'
1075            | b'a'..=b'z'
1076            | b'0'..=b'9'
1077            | b'-'
1078            | b'_'
1079            | b'.'
1080            | b'~'
1081            | b'/'
1082            | b'?'
1083            | b'='
1084            | b'&'
1085            | b'#' => {
1086                out.push(b as char);
1087            }
1088            _ => {
1089                use std::fmt::Write as _;
1090                write!(&mut out, "%{b:02X}").ok();
1091            }
1092        }
1093    }
1094    out
1095}
1096
1097// ── Login form handlers ────────────────────────────────────────────────────────
1098
1099#[derive(serde::Deserialize)]
1100struct LoginQuery {
1101    next: Option<String>,
1102    error: Option<String>,
1103}
1104
1105#[derive(serde::Deserialize)]
1106struct LoginFormData {
1107    key: String,
1108    next: Option<String>,
1109}
1110
1111async fn auth_login_get(
1112    State(state): State<AppState>,
1113    Query(query): Query<LoginQuery>,
1114    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1115) -> Response {
1116    if state.api_keys.is_empty() {
1117        let mut resp = StatusCode::FOUND.into_response();
1118        resp.headers_mut()
1119            .insert(header::LOCATION, HeaderValue::from_static("/"));
1120        return resp;
1121    }
1122    let has_error = query.error.as_deref() == Some("1");
1123    let next_url = query.next.unwrap_or_default();
1124    let lockout_threshold = state.rate_limiter.auth_lockout_threshold;
1125    Html(
1126        LoginTemplate {
1127            csp_nonce,
1128            has_error,
1129            next_url,
1130            lockout_threshold,
1131        }
1132        .render()
1133        .unwrap_or_else(|e| format!("<pre>Template error: {e}</pre>")),
1134    )
1135    .into_response()
1136}
1137
1138async fn auth_login_post(
1139    State(state): State<AppState>,
1140    axum::extract::ConnectInfo(peer_addr): axum::extract::ConnectInfo<SocketAddr>,
1141    Form(form): Form<LoginFormData>,
1142) -> Response {
1143    let peer_ip = peer_addr.ip();
1144    let next_url = form
1145        .next
1146        .as_deref()
1147        .filter(|s| !s.is_empty())
1148        .unwrap_or("/");
1149    let safe_next = if next_url.starts_with('/') && !next_url.starts_with("//") {
1150        next_url
1151    } else {
1152        "/"
1153    };
1154
1155    let valid = state.api_keys.iter().any(|expected| {
1156        use secrecy::ExposeSecret;
1157        ct_eq(&form.key, expected.expose_secret())
1158    });
1159
1160    if valid {
1161        const SESSION_SECS: u64 = 8 * 3600;
1162        let session_id = uuid::Uuid::new_v4().to_string();
1163        let expiry = Instant::now() + Duration::from_secs(SESSION_SECS);
1164        state
1165            .sessions
1166            .lock()
1167            .unwrap_or_else(std::sync::PoisonError::into_inner)
1168            .insert(session_id.clone(), expiry);
1169        let secure_flag = if state.tls_enabled { "; Secure" } else { "" };
1170        let cookie_value = format!(
1171            "sloc_session={session_id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_SECS}{secure_flag}",
1172        );
1173        let location =
1174            HeaderValue::from_str(safe_next).unwrap_or_else(|_| HeaderValue::from_static("/"));
1175        let cookie_hv = HeaderValue::from_str(&cookie_value)
1176            .unwrap_or_else(|_| HeaderValue::from_static("sloc_session=; Path=/; HttpOnly"));
1177        let mut resp = StatusCode::FOUND.into_response();
1178        resp.headers_mut().insert(header::LOCATION, location);
1179        resp.headers_mut().insert(header::SET_COOKIE, cookie_hv);
1180        resp
1181    } else {
1182        state.rate_limiter.record_auth_failure(peer_ip);
1183        tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = "/auth/login",
1184            "Login form authentication failed");
1185        let error_url = format!("/auth/login?next={}&error=1", urlencode_path(safe_next));
1186        let location = HeaderValue::from_str(&error_url)
1187            .unwrap_or_else(|_| HeaderValue::from_static("/auth/login?error=1"));
1188        let mut resp = StatusCode::FOUND.into_response();
1189        resp.headers_mut().insert(header::LOCATION, location);
1190        resp
1191    }
1192}
1193
1194fn build_cors_layer(server_mode: bool) -> CorsLayer {
1195    if server_mode {
1196        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1197            .unwrap_or_default()
1198            .split(',')
1199            .filter(|s| !s.is_empty())
1200            .filter_map(|s| s.trim().parse().ok())
1201            .collect();
1202        if allowed.is_empty() {
1203            return CorsLayer::new();
1204        }
1205        CorsLayer::new()
1206            .allow_origin(AllowOrigin::list(allowed))
1207            .allow_methods(AllowMethods::list([
1208                axum::http::Method::GET,
1209                axum::http::Method::POST,
1210            ]))
1211            .allow_headers(AllowHeaders::list([
1212                axum::http::header::AUTHORIZATION,
1213                axum::http::header::CONTENT_TYPE,
1214            ]))
1215    } else {
1216        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1217            let s = origin.to_str().unwrap_or("");
1218            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1219        }))
1220    }
1221}
1222
1223async fn add_security_headers(
1224    State(state): State<AppState>,
1225    mut req: Request<Body>,
1226    next: Next,
1227) -> Response {
1228    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1229    req.extensions_mut().insert(CspNonce(nonce.clone()));
1230    let mut resp = next.run(req).await;
1231    let h = resp.headers_mut();
1232    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1233    h.insert(
1234        "X-Content-Type-Options",
1235        HeaderValue::from_static("nosniff"),
1236    );
1237    h.insert(
1238        "Referrer-Policy",
1239        HeaderValue::from_static("strict-origin-when-cross-origin"),
1240    );
1241    let csp = format!(
1242        "default-src 'self'; \
1243         style-src 'self' 'nonce-{nonce}'; \
1244         img-src 'self' data: blob:; \
1245         script-src 'self' 'nonce-{nonce}'; \
1246         font-src 'self' data:; \
1247         object-src 'none'; \
1248         frame-ancestors 'none'"
1249    );
1250    h.insert(
1251        "Content-Security-Policy",
1252        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1253            HeaderValue::from_static(
1254                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1255            )
1256        }),
1257    );
1258    h.insert(
1259        "X-Permitted-Cross-Domain-Policies",
1260        HeaderValue::from_static("none"),
1261    );
1262    h.insert(
1263        "Permissions-Policy",
1264        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1265    );
1266    h.insert(
1267        "Cross-Origin-Opener-Policy",
1268        HeaderValue::from_static("same-origin"),
1269    );
1270    h.insert(
1271        "Cross-Origin-Resource-Policy",
1272        HeaderValue::from_static("same-origin"),
1273    );
1274    if state.tls_enabled {
1275        h.insert(
1276            "Strict-Transport-Security",
1277            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1278        );
1279    }
1280    resp
1281}
1282
1283async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1284    let ip = req
1285        .extensions()
1286        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1287        .map(|c| c.0.ip())
1288        .or_else(|| {
1289            if state.trust_proxy {
1290                req.headers()
1291                    .get("X-Forwarded-For")
1292                    .and_then(|v| v.to_str().ok())
1293                    .and_then(|s| s.split(',').next())
1294                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1295            } else {
1296                None
1297            }
1298        })
1299        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1300
1301    if !state.rate_limiter.is_allowed(ip) {
1302        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1303            path = %req.uri().path(), "Rate limit exceeded");
1304        return (
1305            StatusCode::TOO_MANY_REQUESTS,
1306            [(header::RETRY_AFTER, "60")],
1307            "429 Too Many Requests\n",
1308        )
1309            .into_response();
1310    }
1311    next.run(req).await
1312}
1313
1314async fn splash(
1315    State(state): State<AppState>,
1316    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1317) -> impl IntoResponse {
1318    let lan_ip = if state.server_mode {
1319        primary_lan_ip()
1320    } else {
1321        None
1322    };
1323    let port = state
1324        .base_config
1325        .web
1326        .bind_address
1327        .rsplit(':')
1328        .next()
1329        .and_then(|p| p.parse::<u16>().ok())
1330        .unwrap_or(4317);
1331    let template = SplashTemplate {
1332        csp_nonce,
1333        server_mode: state.server_mode,
1334        lan_ip,
1335        port,
1336        version: env!("CARGO_PKG_VERSION"),
1337    };
1338    Html(
1339        template
1340            .render()
1341            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1342    )
1343}
1344
1345async fn index(
1346    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1347    Query(query): Query<IndexQuery>,
1348) -> impl IntoResponse {
1349    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1350        let policy = query
1351            .mixed_line_policy
1352            .unwrap_or_else(|| "code_only".to_string());
1353        let behavior = query
1354            .binary_file_behavior
1355            .unwrap_or_else(|| "skip".to_string());
1356        let cfg = ScanConfig {
1357            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1358            path: query.path.unwrap_or_default(),
1359            include_globs: query.include_globs.unwrap_or_default(),
1360            exclude_globs: query.exclude_globs.unwrap_or_default(),
1361            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1362            mixed_line_policy: policy,
1363            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1364                != Some("off"),
1365            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1366            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1367            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1368                != Some("disabled"),
1369            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1370            binary_file_behavior: behavior,
1371            output_dir: query.output_dir.unwrap_or_default(),
1372            report_title: query.report_title.unwrap_or_default(),
1373            generate_html: query.generate_html.as_deref() != Some("off"),
1374            generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1375        };
1376        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1377    } else {
1378        "{}".to_string()
1379    };
1380
1381    let git_repo = query.git_repo.unwrap_or_default();
1382    let git_ref = query.git_ref.unwrap_or_default();
1383
1384    let git_label = make_git_label(&git_repo, &git_ref);
1385    let git_output_dir = if git_label.is_empty() {
1386        String::new()
1387    } else {
1388        desktop_dir().join(&git_label).display().to_string()
1389    };
1390    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1391    let git_output_dir_json =
1392        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1393
1394    let template = IndexTemplate {
1395        version: env!("CARGO_PKG_VERSION"),
1396        prefill_json,
1397        csp_nonce,
1398        git_repo,
1399        git_ref,
1400        git_label_json,
1401        git_output_dir_json,
1402    };
1403
1404    Html(
1405        template
1406            .render()
1407            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1408    )
1409}
1410
1411async fn scan_setup_handler(
1412    State(state): State<AppState>,
1413    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1414) -> impl IntoResponse {
1415    let recent_scans_json = {
1416        let arr: Vec<serde_json::Value> = {
1417            let reg = state.registry.lock().await;
1418            reg.entries
1419                .iter()
1420                .rev()
1421                .take(6)
1422                .map(|e| {
1423                    let run_dir = e
1424                        .html_path
1425                        .as_ref()
1426                        .or(e.json_path.as_ref())
1427                        .and_then(|p| p.parent().map(PathBuf::from));
1428                    let config_val: Option<serde_json::Value> = run_dir
1429                        .and_then(|d| find_scan_config_in_dir(&d))
1430                        .and_then(|p| fs::read_to_string(&p).ok())
1431                        .and_then(|s| serde_json::from_str(&s).ok());
1432                    serde_json::json!({
1433                        "project_label": e.project_label,
1434                        "timestamp": fmt_la_time(e.timestamp_utc),
1435                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1436                        "config": config_val,
1437                    })
1438                })
1439                .collect()
1440        };
1441        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1442    };
1443
1444    let template = ScanSetupTemplate {
1445        version: env!("CARGO_PKG_VERSION"),
1446        recent_scans_json,
1447        csp_nonce,
1448    };
1449    Html(
1450        template
1451            .render()
1452            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1453    )
1454}
1455
1456async fn healthz() -> &'static str {
1457    "ok"
1458}
1459
1460async fn api_docs_handler(
1461    State(state): State<AppState>,
1462    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1463) -> impl IntoResponse {
1464    let has_api_key = !state.api_keys.is_empty();
1465    Html(
1466        ApiDocsTemplate {
1467            has_api_key,
1468            csp_nonce,
1469            version: env!("CARGO_PKG_VERSION"),
1470        }
1471        .render()
1472        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1473    )
1474}
1475
1476async fn chart_js_handler() -> impl IntoResponse {
1477    (
1478        [(
1479            header::CONTENT_TYPE,
1480            "application/javascript; charset=utf-8",
1481        )],
1482        CHART_JS,
1483    )
1484}
1485
1486#[derive(Debug, Deserialize)]
1487struct AnalyzeForm {
1488    path: String,
1489    git_repo: Option<String>,
1490    git_ref: Option<String>,
1491    mixed_line_policy: Option<MixedLinePolicy>,
1492    python_docstrings_as_comments: Option<String>,
1493    generated_file_detection: Option<String>,
1494    minified_file_detection: Option<String>,
1495    vendor_directory_detection: Option<String>,
1496    include_lockfiles: Option<String>,
1497    binary_file_behavior: Option<BinaryFileBehavior>,
1498    output_dir: Option<String>,
1499    report_title: Option<String>,
1500    report_header_footer: Option<String>,
1501    generate_html: Option<String>,
1502    generate_pdf: Option<String>,
1503    include_globs: Option<String>,
1504    exclude_globs: Option<String>,
1505    submodule_breakdown: Option<String>,
1506    coverage_file: Option<String>,
1507}
1508
1509#[allow(clippy::struct_excessive_bools)]
1510#[derive(Debug, Serialize, Deserialize, Clone)]
1511struct ScanConfig {
1512    oxide_sloc_version: String,
1513    path: String,
1514    include_globs: String,
1515    exclude_globs: String,
1516    submodule_breakdown: bool,
1517    mixed_line_policy: String,
1518    python_docstrings_as_comments: bool,
1519    generated_file_detection: bool,
1520    minified_file_detection: bool,
1521    vendor_directory_detection: bool,
1522    include_lockfiles: bool,
1523    binary_file_behavior: String,
1524    output_dir: String,
1525    report_title: String,
1526    generate_html: bool,
1527    generate_pdf: bool,
1528}
1529
1530#[derive(Debug, Deserialize, Default)]
1531struct IndexQuery {
1532    path: Option<String>,
1533    include_globs: Option<String>,
1534    exclude_globs: Option<String>,
1535    submodule_breakdown: Option<String>,
1536    mixed_line_policy: Option<String>,
1537    python_docstrings_as_comments: Option<String>,
1538    generated_file_detection: Option<String>,
1539    minified_file_detection: Option<String>,
1540    vendor_directory_detection: Option<String>,
1541    include_lockfiles: Option<String>,
1542    binary_file_behavior: Option<String>,
1543    output_dir: Option<String>,
1544    report_title: Option<String>,
1545    generate_html: Option<String>,
1546    generate_pdf: Option<String>,
1547    prefilled: Option<String>,
1548    git_repo: Option<String>,
1549    git_ref: Option<String>,
1550}
1551
1552#[derive(Debug, Deserialize)]
1553struct PreviewQuery {
1554    path: Option<String>,
1555    include_globs: Option<String>,
1556    exclude_globs: Option<String>,
1557}
1558
1559#[cfg(feature = "native-dialog")]
1560#[derive(Debug, Deserialize)]
1561struct PickDirectoryQuery {
1562    kind: Option<String>,
1563    current: Option<String>,
1564}
1565
1566#[cfg(not(feature = "native-dialog"))]
1567#[derive(Debug, Deserialize)]
1568struct PickDirectoryQuery {}
1569
1570#[derive(Debug, Deserialize, Default)]
1571struct ArtifactQuery {
1572    download: Option<String>,
1573}
1574
1575#[cfg(feature = "native-dialog")]
1576#[derive(Debug, Serialize)]
1577struct PickDirectoryResponse {
1578    selected_path: Option<String>,
1579    cancelled: bool,
1580}
1581
1582#[cfg(feature = "native-dialog")]
1583async fn pick_directory_handler(
1584    State(state): State<AppState>,
1585    Query(query): Query<PickDirectoryQuery>,
1586) -> Response {
1587    if state.server_mode {
1588        return StatusCode::NOT_FOUND.into_response();
1589    }
1590
1591    let is_coverage = query.kind.as_deref() == Some("coverage");
1592    let title = match query.kind.as_deref() {
1593        Some("output") => "Select output directory",
1594        Some("reports") => "Select folder containing saved reports",
1595        Some("coverage") => "Select LCOV coverage file",
1596        _ => "Select project directory",
1597    }
1598    .to_owned();
1599    let current = query.current.clone();
1600
1601    let picked = tokio::task::spawn_blocking(move || {
1602        // Windows: attach to the foreground thread so the dialog inherits focus,
1603        // and kick off a watcher that flashes the dialog once it appears.
1604        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1605        let fg_tid = win_dialog_focus::attach_to_foreground();
1606        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1607        win_dialog_focus::flash_dialog_when_ready(title.clone());
1608
1609        let mut dialog = rfd::FileDialog::new().set_title(&title);
1610        if let Some(current) = current.as_deref() {
1611            let resolved = resolve_input_path(current);
1612            let seed = if resolved.is_dir() {
1613                Some(resolved)
1614            } else {
1615                resolved.parent().map(Path::to_path_buf)
1616            };
1617            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1618                dialog = dialog.set_directory(seed_dir);
1619            }
1620        }
1621        let result = if is_coverage {
1622            dialog
1623                .add_filter(
1624                    "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1625                    &["info", "lcov", "xml"],
1626                )
1627                .pick_file()
1628        } else {
1629            dialog.pick_folder()
1630        };
1631
1632        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1633        win_dialog_focus::detach_from_foreground(fg_tid);
1634
1635        result
1636    })
1637    .await
1638    .unwrap_or(None);
1639
1640    Json(PickDirectoryResponse {
1641        selected_path: picked.as_ref().map(|p| display_path(p)),
1642        cancelled: picked.is_none(),
1643    })
1644    .into_response()
1645}
1646
1647#[cfg(not(feature = "native-dialog"))]
1648async fn pick_directory_handler(
1649    State(_state): State<AppState>,
1650    Query(_query): Query<PickDirectoryQuery>,
1651) -> Response {
1652    StatusCode::NOT_FOUND.into_response()
1653}
1654
1655#[cfg(feature = "native-dialog")]
1656async fn pick_file_handler(State(state): State<AppState>) -> Response {
1657    if state.server_mode {
1658        return StatusCode::NOT_FOUND.into_response();
1659    }
1660    let picked = tokio::task::spawn_blocking(|| {
1661        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1662        let fg_tid = win_dialog_focus::attach_to_foreground();
1663        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1664        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1665
1666        let result = rfd::FileDialog::new()
1667            .set_title("Select HTML report")
1668            .add_filter("HTML report", &["html"])
1669            .pick_file();
1670
1671        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1672        win_dialog_focus::detach_from_foreground(fg_tid);
1673
1674        result
1675    })
1676    .await
1677    .unwrap_or(None);
1678    Json(PickDirectoryResponse {
1679        selected_path: picked.as_ref().map(|p| display_path(p)),
1680        cancelled: picked.is_none(),
1681    })
1682    .into_response()
1683}
1684
1685#[cfg(not(feature = "native-dialog"))]
1686async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1687    StatusCode::NOT_FOUND.into_response()
1688}
1689
1690#[derive(Deserialize)]
1691struct LocateReportForm {
1692    file_path: String,
1693}
1694
1695/// Render a view-reports error page and return it as a `Response`.
1696fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
1697    let html = ErrorTemplate {
1698        message: message.into(),
1699        last_report_url: Some("/view-reports".to_string()),
1700        last_report_label: Some("View Reports".to_string()),
1701        csp_nonce: csp_nonce.to_owned(),
1702    }
1703    .render()
1704    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1705    Html(html).into_response()
1706}
1707
1708/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
1709fn registry_entry_from_run(
1710    run: &AnalysisRun,
1711    json_path: PathBuf,
1712    html_path: PathBuf,
1713) -> RegistryEntry {
1714    let project_label = run.input_roots.first().map_or_else(
1715        || "Unknown Project".to_string(),
1716        |r| sanitize_project_label(r),
1717    );
1718    RegistryEntry {
1719        run_id: run.tool.run_id.clone(),
1720        timestamp_utc: run.tool.timestamp_utc,
1721        project_label,
1722        input_roots: run.input_roots.clone(),
1723        json_path: Some(json_path),
1724        html_path: Some(html_path),
1725        pdf_path: None,
1726        summary: ScanSummarySnapshot {
1727            files_analyzed: run.summary_totals.files_analyzed,
1728            files_skipped: run.summary_totals.files_skipped,
1729            total_physical_lines: run.summary_totals.total_physical_lines,
1730            code_lines: run.summary_totals.code_lines,
1731            comment_lines: run.summary_totals.comment_lines,
1732            blank_lines: run.summary_totals.blank_lines,
1733            functions: run.summary_totals.functions,
1734            classes: run.summary_totals.classes,
1735            variables: run.summary_totals.variables,
1736            imports: run.summary_totals.imports,
1737            test_count: run.summary_totals.test_count,
1738        },
1739        git_branch: None,
1740        git_commit: None,
1741        git_author: None,
1742        git_tags: None,
1743        git_nearest_tag: None,
1744        git_commit_date: None,
1745    }
1746}
1747
1748/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
1749/// immediately without requiring a server restart.
1750pub(crate) async fn register_artifacts_in_registry(
1751    state: &AppState,
1752    label: &str,
1753    run: &AnalysisRun,
1754    artifacts: &RunArtifacts,
1755) {
1756    let Some(json_path) = artifacts.json_path.clone() else {
1757        return;
1758    };
1759    let Some(html_path) = artifacts.html_path.clone() else {
1760        return;
1761    };
1762    let mut entry = registry_entry_from_run(run, json_path, html_path);
1763    entry.project_label = label.to_owned();
1764    let mut reg = state.registry.lock().await;
1765    reg.add_entry(entry);
1766    let _ = reg.save(&state.registry_path);
1767}
1768
1769/// Validate the locate-report form: check extension, resolve the canonical path, enforce
1770/// server-mode root restriction, and extract the parent directory.
1771///
1772/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
1773#[allow(clippy::result_large_err)]
1774fn validate_locate_request(
1775    state: &AppState,
1776    file_path: &str,
1777    csp_nonce: &str,
1778) -> Result<(PathBuf, PathBuf), Response> {
1779    let file_ext = Path::new(file_path)
1780        .extension()
1781        .and_then(|e| e.to_str())
1782        .unwrap_or("")
1783        .to_ascii_lowercase();
1784    if file_ext != "html" {
1785        return Err(locate_report_error(
1786            "Only .html report files can be located via this form.",
1787            csp_nonce,
1788        ));
1789    }
1790    let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1791        Ok(p) => strip_unc_prefix(p),
1792        Err(_) => {
1793            return Err(locate_report_error(
1794                "Report file not found or path is invalid.",
1795                csp_nonce,
1796            ));
1797        }
1798    };
1799    if state.server_mode {
1800        let output_root = resolve_output_root(None);
1801        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1802        if !html_path.starts_with(&canonical_root) {
1803            return Err(locate_report_error(
1804                "Report file must be within the configured output directory.",
1805                csp_nonce,
1806            ));
1807        }
1808    }
1809    let parent = match html_path.parent() {
1810        Some(p) => p.to_path_buf(),
1811        None => {
1812            return Err(locate_report_error(
1813                "Report file has no parent directory.",
1814                csp_nonce,
1815            ));
1816        }
1817    };
1818    Ok((html_path, parent))
1819}
1820
1821/// Return a non-sensitive path hint for error messages (empty in server mode).
1822fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1823    if server_mode {
1824        String::new()
1825    } else {
1826        format!("\n\nFile: {}", path.display())
1827    }
1828}
1829
1830async fn locate_report_handler(
1831    State(state): State<AppState>,
1832    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1833    Form(form): Form<LocateReportForm>,
1834) -> impl IntoResponse {
1835    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1836        Ok(v) => v,
1837        Err(resp) => return resp,
1838    };
1839
1840    let json_candidate = parent.join("result.json");
1841    let mut reg = state.registry.lock().await;
1842    // Find an existing entry whose output directory matches the selected file's parent.
1843    let entry_idx = reg.entries.iter().position(|e| {
1844        let json_match = e
1845            .json_path
1846            .as_ref()
1847            .and_then(|p| p.parent())
1848            .is_some_and(|p| p == parent);
1849        let html_match = e
1850            .html_path
1851            .as_ref()
1852            .and_then(|p| p.parent())
1853            .is_some_and(|p| p == parent);
1854        json_match || html_match
1855    });
1856    if let Some(idx) = entry_idx {
1857        reg.entries[idx].html_path = Some(html_path);
1858        let _ = reg.save(&state.registry_path);
1859        return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1860    }
1861    // No match — attempt to build an entry from an adjacent result.json.
1862    if json_candidate.exists() {
1863        match read_json(&json_candidate) {
1864            Ok(run) => {
1865                let entry = registry_entry_from_run(&run, json_candidate, html_path);
1866                reg.add_entry(entry);
1867                let _ = reg.save(&state.registry_path);
1868                return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1869            }
1870            Err(e) => {
1871                let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1872                let err_detail = if state.server_mode {
1873                    String::new()
1874                } else {
1875                    format!("\n\nError: {e}")
1876                };
1877                return locate_report_error(
1878                    format!(
1879                        "Could not link this report.\n\nA 'result.json' was found but could not \
1880                         be parsed — it may have been saved by an older version of OxideSLOC. \
1881                         Re-running the analysis will create a fresh, compatible \
1882                         record.{file_hint}{err_detail}"
1883                    ),
1884                    &csp_nonce,
1885                );
1886            }
1887        }
1888    }
1889    drop(reg);
1890    let file_hint = locate_path_hint(state.server_mode, &html_path);
1891    locate_report_error(
1892        format!(
1893            "Could not link this report.\n\nNo matching scan record was found, and no \
1894             'result.json' was found in the same folder.{file_hint}"
1895        ),
1896        &csp_nonce,
1897    )
1898}
1899
1900/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
1901fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
1902    fs::read_dir(dir)
1903        .ok()?
1904        .flatten()
1905        .map(|e| e.path())
1906        .find(|p| {
1907            p.is_file()
1908                && p.file_stem()
1909                    .and_then(|n| n.to_str())
1910                    .is_some_and(|n| n.starts_with("result"))
1911                && p.extension()
1912                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
1913        })
1914}
1915
1916#[derive(Deserialize)]
1917struct LocateReportsDirForm {
1918    folder_path: String,
1919}
1920
1921#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
1922async fn locate_reports_dir_handler(
1923    // NOSONAR(rust:S3776)
1924    State(state): State<AppState>,
1925    Form(form): Form<LocateReportsDirForm>,
1926) -> impl IntoResponse {
1927    if state.server_mode {
1928        return StatusCode::NOT_FOUND.into_response();
1929    }
1930    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
1931        Ok(p) => strip_unc_prefix(p),
1932        Err(_) => {
1933            return axum::response::Redirect::to(
1934                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
1935            )
1936            .into_response();
1937        }
1938    };
1939    if !folder.is_dir() {
1940        return axum::response::Redirect::to(
1941            "/view-reports?error=Selected+path+is+not+a+directory.",
1942        )
1943        .into_response();
1944    }
1945
1946    // Collect result*.json candidates: the folder itself and one level of subdirectories.
1947    // Filenames use the pattern result_<project>_<commit>.json — match by prefix/suffix.
1948    let mut candidates: Vec<PathBuf> = Vec::new();
1949    if let Some(j) = find_result_json_in_dir(&folder) {
1950        candidates.push(j);
1951    }
1952    if let Ok(dir_entries) = fs::read_dir(&folder) {
1953        for entry in dir_entries.flatten() {
1954            let sub = entry.path();
1955            if sub.is_dir() {
1956                if let Some(j) = find_result_json_in_dir(&sub) {
1957                    candidates.push(j);
1958                }
1959            }
1960        }
1961    }
1962
1963    if candidates.is_empty() {
1964        return axum::response::Redirect::to(
1965            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
1966        )
1967        .into_response();
1968    }
1969
1970    let mut linked_count: usize = 0;
1971    let mut reg = state.registry.lock().await;
1972    for json_path in candidates {
1973        let parent = match json_path.parent() {
1974            Some(p) => p.to_path_buf(),
1975            None => continue,
1976        };
1977        // Skip if this directory is already registered AND the artifact still exists on disk.
1978        // A stale entry (file moved/deleted) must not block re-scanning the same directory.
1979        let already = reg.entries.iter().any(|e| {
1980            let dir_match = e
1981                .json_path
1982                .as_ref()
1983                .and_then(|p| p.parent())
1984                .is_some_and(|p| p == parent)
1985                || e.html_path
1986                    .as_ref()
1987                    .and_then(|p| p.parent())
1988                    .is_some_and(|p| p == parent);
1989            dir_match
1990                && (e.json_path.as_ref().is_some_and(|p| p.exists())
1991                    || e.html_path.as_ref().is_some_and(|p| p.exists()))
1992        });
1993        if already {
1994            continue;
1995        }
1996        // Find the first .html file in the same directory.
1997        let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
1998            rd.flatten()
1999                .map(|e| e.path())
2000                .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2001        });
2002        let Ok(run) = read_json(&json_path) else {
2003            continue;
2004        };
2005        let project_label = run.input_roots.first().map_or_else(
2006            || "Unknown Project".to_string(),
2007            |r| sanitize_project_label(r),
2008        );
2009        let entry = RegistryEntry {
2010            run_id: run.tool.run_id.clone(),
2011            timestamp_utc: run.tool.timestamp_utc,
2012            project_label,
2013            input_roots: run.input_roots.clone(),
2014            json_path: Some(json_path),
2015            html_path,
2016            pdf_path: None,
2017            summary: ScanSummarySnapshot {
2018                files_analyzed: run.summary_totals.files_analyzed,
2019                files_skipped: run.summary_totals.files_skipped,
2020                total_physical_lines: run.summary_totals.total_physical_lines,
2021                code_lines: run.summary_totals.code_lines,
2022                comment_lines: run.summary_totals.comment_lines,
2023                blank_lines: run.summary_totals.blank_lines,
2024                functions: run.summary_totals.functions,
2025                classes: run.summary_totals.classes,
2026                variables: run.summary_totals.variables,
2027                imports: run.summary_totals.imports,
2028                test_count: run.summary_totals.test_count,
2029            },
2030            git_branch: run.git_branch.clone(),
2031            git_commit: run.git_commit_short.clone(),
2032            git_author: run.git_commit_author.clone(),
2033            git_tags: run.git_tags.clone(),
2034            git_nearest_tag: run.git_nearest_tag.clone(),
2035            git_commit_date: run.git_commit_date.clone(),
2036        };
2037        reg.add_entry(entry);
2038        linked_count += 1;
2039    }
2040    let _ = reg.save(&state.registry_path);
2041    drop(reg);
2042
2043    if linked_count == 0 {
2044        return axum::response::Redirect::to(
2045            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2046        )
2047        .into_response();
2048    }
2049    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2050}
2051
2052#[derive(Deserialize)]
2053struct RelocateScanForm {
2054    run_id: String,
2055    folder_path: String,
2056    redirect_url: String,
2057}
2058
2059#[allow(clippy::too_many_lines)] // scan relocation handler with inline HTML rendering
2060async fn relocate_scan_handler(
2061    // NOSONAR(rust:S3776)
2062    State(state): State<AppState>,
2063    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2064    Form(form): Form<RelocateScanForm>,
2065) -> impl IntoResponse {
2066    if state.server_mode {
2067        return StatusCode::NOT_FOUND.into_response();
2068    }
2069
2070    let run_id = form.run_id.trim().to_string();
2071    let redirect_url = form.redirect_url.trim().to_string();
2072
2073    let run_exists = {
2074        let reg = state.registry.lock().await;
2075        reg.find_by_run_id(&run_id).is_some()
2076    };
2077    if !run_exists {
2078        let html = ErrorTemplate {
2079            message: format!("Run ID '{run_id}' not found in registry."),
2080            last_report_url: Some("/compare-scans".to_string()),
2081            last_report_label: Some("Compare Scans".to_string()),
2082            csp_nonce: csp_nonce.clone(),
2083        }
2084        .render()
2085        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2086        return Html(html).into_response();
2087    }
2088
2089    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2090        Ok(p) => strip_unc_prefix(p),
2091        Err(_) => {
2092            return missing_scan_relocate_response(
2093                "Folder not found or path is invalid.",
2094                &run_id,
2095                form.folder_path.trim(),
2096                &redirect_url,
2097                false,
2098                &csp_nonce,
2099            );
2100        }
2101    };
2102
2103    if !folder.is_dir() {
2104        return missing_scan_relocate_response(
2105            "Selected path is not a directory.",
2106            &run_id,
2107            &folder.display().to_string(),
2108            &redirect_url,
2109            false,
2110            &csp_nonce,
2111        );
2112    }
2113
2114    let json_candidates: Vec<PathBuf> = fs::read_dir(&folder)
2115        .ok()
2116        .into_iter()
2117        .flatten()
2118        .flatten()
2119        .map(|e| e.path())
2120        .filter(|p| {
2121            p.is_file()
2122                && p.file_stem()
2123                    .and_then(|n| n.to_str())
2124                    .is_some_and(|n| n.starts_with("result"))
2125                && p.extension()
2126                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2127        })
2128        .collect();
2129
2130    if json_candidates.is_empty() {
2131        return missing_scan_relocate_response(
2132            &format!(
2133                "No result JSON files found in the selected folder.\nSearched: {}",
2134                folder.display()
2135            ),
2136            &run_id,
2137            &folder.display().to_string(),
2138            &redirect_url,
2139            false,
2140            &csp_nonce,
2141        );
2142    }
2143
2144    let mut matched_json: Option<PathBuf> = None;
2145    for candidate in &json_candidates {
2146        if let Ok(run) = read_json(candidate) {
2147            if run.tool.run_id == run_id {
2148                matched_json = Some(candidate.clone());
2149                break;
2150            }
2151        }
2152    }
2153
2154    let Some(json_path) = matched_json else {
2155        return missing_scan_relocate_response(
2156            &format!(
2157                "No matching scan found in the selected folder.\n\
2158                 The JSON files present do not contain run ID: {run_id}\n\
2159                 Searched: {}",
2160                folder.display()
2161            ),
2162            &run_id,
2163            &folder.display().to_string(),
2164            &redirect_url,
2165            false,
2166            &csp_nonce,
2167        );
2168    };
2169
2170    let html_path = fs::read_dir(&folder)
2171        .ok()
2172        .into_iter()
2173        .flatten()
2174        .flatten()
2175        .map(|e| e.path())
2176        .find(|p| {
2177            p.is_file()
2178                && p.file_stem()
2179                    .and_then(|n| n.to_str())
2180                    .is_some_and(|n| n.starts_with("result"))
2181                && p.extension()
2182                    .is_some_and(|e| e.eq_ignore_ascii_case("html"))
2183        });
2184    let pdf_path = fs::read_dir(&folder)
2185        .ok()
2186        .into_iter()
2187        .flatten()
2188        .flatten()
2189        .map(|e| e.path())
2190        .find(|p| {
2191            p.is_file()
2192                && p.file_stem()
2193                    .and_then(|n| n.to_str())
2194                    .is_some_and(|n| n.starts_with("result"))
2195                && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
2196        });
2197
2198    {
2199        let mut reg = state.registry.lock().await;
2200        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2201            entry.json_path = Some(json_path);
2202            if let Some(hp) = html_path {
2203                entry.html_path = Some(hp);
2204            }
2205            if let Some(pp) = pdf_path {
2206                entry.pdf_path = Some(pp);
2207            }
2208        }
2209        let _ = reg.save(&state.registry_path);
2210    }
2211
2212    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
2213        redirect_url
2214    } else {
2215        "/compare-scans".to_string()
2216    };
2217    axum::response::Redirect::to(&safe_redirect).into_response()
2218}
2219
2220fn missing_scan_relocate_response(
2221    message: &str,
2222    run_id: &str,
2223    folder_hint: &str,
2224    redirect_url: &str,
2225    server_mode: bool,
2226    csp_nonce: &str,
2227) -> axum::response::Response {
2228    let html = RelocateScanTemplate {
2229        message: message.to_string(),
2230        run_id: run_id.to_string(),
2231        folder_hint: folder_hint.to_string(),
2232        redirect_url: redirect_url.to_string(),
2233        server_mode,
2234        csp_nonce: csp_nonce.to_owned(),
2235    }
2236    .render()
2237    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2238    (StatusCode::NOT_FOUND, Html(html)).into_response()
2239}
2240
2241// ── Watched-directory helpers ─────────────────────────────────────────────────
2242
2243/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
2244/// Returns the number of newly linked entries.
2245fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
2246    let mut candidates: Vec<PathBuf> = Vec::new();
2247    if let Some(j) = find_result_json_in_dir(folder) {
2248        candidates.push(j);
2249    }
2250    if let Ok(dir_entries) = fs::read_dir(folder) {
2251        for entry in dir_entries.flatten() {
2252            let sub = entry.path();
2253            if sub.is_dir() {
2254                if let Some(j) = find_result_json_in_dir(&sub) {
2255                    candidates.push(j);
2256                }
2257            }
2258        }
2259    }
2260
2261    let mut linked = 0usize;
2262    for json_path in candidates {
2263        let parent = match json_path.parent() {
2264            Some(p) => p.to_path_buf(),
2265            None => continue,
2266        };
2267        let already = reg.entries.iter().any(|e| {
2268            let dir_match = e
2269                .json_path
2270                .as_ref()
2271                .and_then(|p| p.parent())
2272                .is_some_and(|p| p == parent)
2273                || e.html_path
2274                    .as_ref()
2275                    .and_then(|p| p.parent())
2276                    .is_some_and(|p| p == parent);
2277            dir_match
2278                && (e.json_path.as_ref().is_some_and(|p| p.exists())
2279                    || e.html_path.as_ref().is_some_and(|p| p.exists()))
2280        });
2281        if already {
2282            continue;
2283        }
2284        let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2285            rd.flatten()
2286                .map(|e| e.path())
2287                .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2288        });
2289        let Ok(run) = read_json(&json_path) else {
2290            continue;
2291        };
2292        let project_label = run.input_roots.first().map_or_else(
2293            || "Unknown Project".to_string(),
2294            |r| sanitize_project_label(r),
2295        );
2296        let entry = RegistryEntry {
2297            run_id: run.tool.run_id.clone(),
2298            timestamp_utc: run.tool.timestamp_utc,
2299            project_label,
2300            input_roots: run.input_roots.clone(),
2301            json_path: Some(json_path),
2302            html_path,
2303            pdf_path: None,
2304            summary: ScanSummarySnapshot {
2305                files_analyzed: run.summary_totals.files_analyzed,
2306                files_skipped: run.summary_totals.files_skipped,
2307                total_physical_lines: run.summary_totals.total_physical_lines,
2308                code_lines: run.summary_totals.code_lines,
2309                comment_lines: run.summary_totals.comment_lines,
2310                blank_lines: run.summary_totals.blank_lines,
2311                functions: run.summary_totals.functions,
2312                classes: run.summary_totals.classes,
2313                variables: run.summary_totals.variables,
2314                imports: run.summary_totals.imports,
2315                test_count: run.summary_totals.test_count,
2316            },
2317            git_branch: run.git_branch.clone(),
2318            git_commit: run.git_commit_short.clone(),
2319            git_author: run.git_commit_author.clone(),
2320            git_tags: run.git_tags.clone(),
2321            git_nearest_tag: run.git_nearest_tag.clone(),
2322            git_commit_date: run.git_commit_date.clone(),
2323        };
2324        reg.add_entry(entry);
2325        linked += 1;
2326    }
2327    linked
2328}
2329
2330/// Scan all watched directories (plus the default output root) into `reg`.
2331async fn auto_scan_watched_dirs(state: &AppState) {
2332    let dirs: Vec<PathBuf> = {
2333        let wd = state.watched_dirs.lock().await;
2334        wd.dirs.clone()
2335    };
2336    if dirs.is_empty() {
2337        return;
2338    }
2339    let mut reg = state.registry.lock().await;
2340    let mut total = 0usize;
2341    for dir in &dirs {
2342        if dir.is_dir() {
2343            total += scan_folder_into_registry(dir, &mut reg);
2344        }
2345    }
2346    if total > 0 {
2347        let _ = reg.save(&state.registry_path);
2348    }
2349}
2350
2351// ── Watched-dir route forms ───────────────────────────────────────────────────
2352
2353#[derive(Deserialize)]
2354struct WatchedDirForm {
2355    folder_path: String,
2356    #[serde(default = "default_redirect")]
2357    redirect_to: String,
2358}
2359
2360fn default_redirect() -> String {
2361    "/view-reports".to_string()
2362}
2363
2364#[derive(Deserialize)]
2365struct WatchedDirRefreshForm {
2366    #[serde(default = "default_redirect")]
2367    redirect_to: String,
2368}
2369
2370// ── Watched-dir helpers ───────────────────────────────────────────────────────
2371
2372/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
2373fn safe_redirect(dest: &str) -> &str {
2374    if dest.starts_with('/') {
2375        dest
2376    } else {
2377        "/"
2378    }
2379}
2380
2381// ── Watched-dir handlers ──────────────────────────────────────────────────────
2382
2383async fn add_watched_dir_handler(
2384    State(state): State<AppState>,
2385    Form(form): Form<WatchedDirForm>,
2386) -> impl IntoResponse {
2387    if state.server_mode {
2388        return StatusCode::NOT_FOUND.into_response();
2389    }
2390    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2391        strip_unc_prefix(p)
2392    } else {
2393        let dest = format!(
2394            "{}?error=Folder+not+found+or+path+is+invalid.",
2395            safe_redirect(&form.redirect_to)
2396        );
2397        return axum::response::Redirect::to(&dest).into_response();
2398    };
2399    if !folder.is_dir() {
2400        let dest = format!(
2401            "{}?error=Selected+path+is+not+a+directory.",
2402            safe_redirect(&form.redirect_to)
2403        );
2404        return axum::response::Redirect::to(&dest).into_response();
2405    }
2406
2407    // Persist the watched directory.
2408    {
2409        let mut wd = state.watched_dirs.lock().await;
2410        wd.add(folder.clone());
2411        let _ = wd.save(&state.watched_dirs_path);
2412    }
2413
2414    // Immediately scan the folder and add any new reports.
2415    let linked = {
2416        let mut reg = state.registry.lock().await;
2417        let n = scan_folder_into_registry(&folder, &mut reg);
2418        if n > 0 {
2419            let _ = reg.save(&state.registry_path);
2420        }
2421        n
2422    };
2423
2424    let dest = if linked > 0 {
2425        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2426    } else {
2427        format!(
2428            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2429            safe_redirect(&form.redirect_to)
2430        )
2431    };
2432    axum::response::Redirect::to(&dest).into_response()
2433}
2434
2435async fn remove_watched_dir_handler(
2436    State(state): State<AppState>,
2437    Form(form): Form<WatchedDirForm>,
2438) -> impl IntoResponse {
2439    if state.server_mode {
2440        return StatusCode::NOT_FOUND.into_response();
2441    }
2442    let folder = PathBuf::from(&form.folder_path);
2443    {
2444        let mut wd = state.watched_dirs.lock().await;
2445        wd.remove(&folder);
2446        let _ = wd.save(&state.watched_dirs_path);
2447    }
2448    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2449}
2450
2451async fn refresh_watched_dirs_handler(
2452    State(state): State<AppState>,
2453    Form(form): Form<WatchedDirRefreshForm>,
2454) -> impl IntoResponse {
2455    if state.server_mode {
2456        return StatusCode::NOT_FOUND.into_response();
2457    }
2458    let dirs: Vec<PathBuf> = {
2459        let wd = state.watched_dirs.lock().await;
2460        wd.dirs.clone()
2461    };
2462    let mut total = 0usize;
2463    {
2464        let mut reg = state.registry.lock().await;
2465        for dir in &dirs {
2466            if dir.is_dir() {
2467                total += scan_folder_into_registry(dir, &mut reg);
2468            }
2469        }
2470        if total > 0 {
2471            let _ = reg.save(&state.registry_path);
2472        }
2473    }
2474    let dest = if total > 0 {
2475        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2476    } else {
2477        safe_redirect(&form.redirect_to).to_owned()
2478    };
2479    axum::response::Redirect::to(&dest).into_response()
2480}
2481
2482#[derive(Debug, Deserialize)]
2483struct OpenPathQuery {
2484    path: Option<String>,
2485}
2486
2487async fn open_path_handler(
2488    State(state): State<AppState>,
2489    Query(query): Query<OpenPathQuery>,
2490) -> impl IntoResponse {
2491    if state.server_mode {
2492        return StatusCode::NOT_FOUND.into_response();
2493    }
2494    let raw = match query.path.as_deref() {
2495        Some(p) if !p.is_empty() => p,
2496        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2497    };
2498
2499    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
2500    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
2501    // so the file explorer still opens somewhere useful.
2502    let target = match fs::canonicalize(raw) {
2503        Ok(canonical) if canonical.is_file() => match canonical.parent() {
2504            Some(p) => p.to_path_buf(),
2505            None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2506        },
2507        Ok(canonical) if canonical.is_dir() => canonical,
2508        Ok(_) => {
2509            return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2510        }
2511        Err(_) => {
2512            // Path doesn't exist — find nearest existing ancestor directory.
2513            let mut ancestor = std::path::Path::new(raw);
2514            loop {
2515                match ancestor.parent() {
2516                    Some(p) => {
2517                        ancestor = p;
2518                        if ancestor.is_dir() {
2519                            break;
2520                        }
2521                    }
2522                    None => {
2523                        return (StatusCode::BAD_REQUEST, "no existing ancestor found")
2524                            .into_response();
2525                    }
2526                }
2527            }
2528            ancestor.to_path_buf()
2529        }
2530    };
2531
2532    #[cfg(target_os = "windows")]
2533    {
2534        // Open the folder in Explorer, then use SetForegroundWindow + ShowWindow(SW_MAXIMIZE=3)
2535        // to ensure the window surfaces on top of all other windows.  The path is passed via
2536        // an environment variable to avoid any command-injection or escaping issues.
2537        let ps_cmd = "Add-Type -TypeDefinition \
2538            'using System;using System.Runtime.InteropServices;\
2539            public class WF{\
2540              [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
2541              [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
2542            }'; \
2543            $p=$env:SLOC_OPEN_PATH; \
2544            $sh=New-Object -ComObject Shell.Application; \
2545            $sh.Open($p); \
2546            Start-Sleep -Milliseconds 600; \
2547            foreach($w in $sh.Windows()){ \
2548              try{ \
2549                if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
2550                   [System.IO.Path]::GetFullPath($p)){ \
2551                  [WF]::ShowWindow($w.HWND,3); \
2552                  [WF]::SetForegroundWindow($w.HWND); \
2553                  break \
2554                } \
2555              }catch{} \
2556            }";
2557        let _ = std::process::Command::new("powershell")
2558            .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
2559            .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
2560            .stdout(Stdio::null())
2561            .stderr(Stdio::null())
2562            .spawn();
2563    }
2564    #[cfg(target_os = "macos")]
2565    let _ = std::process::Command::new("open")
2566        .arg(&target)
2567        .stdout(Stdio::null())
2568        .stderr(Stdio::null())
2569        .spawn();
2570    #[cfg(target_os = "linux")]
2571    let _ = std::process::Command::new("xdg-open")
2572        .arg(&target)
2573        .stdout(Stdio::null())
2574        .stderr(Stdio::null())
2575        .spawn();
2576
2577    (StatusCode::OK, "ok").into_response()
2578}
2579
2580async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
2581    let (content_type, bytes): (&'static str, &'static [u8]) =
2582        match (folder.as_str(), file.as_str()) {
2583            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
2584            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
2585            ("icons", "c.png") => ("image/png", IMG_ICON_C),
2586            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
2587            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
2588            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
2589            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
2590            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
2591            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
2592            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
2593            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
2594            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
2595            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
2596            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
2597            ("icons", "r.png") => ("image/png", IMG_ICON_R),
2598            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
2599            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
2600            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
2601            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
2602            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
2603            _ => return StatusCode::NOT_FOUND.into_response(),
2604        };
2605    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
2606}
2607
2608async fn preview_handler(
2609    State(state): State<AppState>,
2610    Query(query): Query<PreviewQuery>,
2611) -> impl IntoResponse {
2612    let raw_path = query
2613        .path
2614        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
2615    let resolved = resolve_input_path(&raw_path);
2616
2617    if state.server_mode {
2618        let config = &state.base_config;
2619        if config.discovery.allowed_scan_roots.is_empty() {
2620            return Html(
2621                r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
2622            );
2623        }
2624        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
2625        let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2626            fs::canonicalize(root)
2627                .ok()
2628                .is_some_and(|r| canonical.starts_with(&r))
2629        });
2630        if !allowed {
2631            return Html(
2632                r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
2633            );
2634        }
2635    }
2636
2637    let include_patterns = split_patterns(query.include_globs.as_deref());
2638    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
2639
2640    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
2641        Ok(html) => Html(html),
2642        Err(err) => Html(format!(
2643            r#"<div class="preview-error">Preview failed: {}</div>"#,
2644            escape_html(&err.to_string())
2645        )),
2646    }
2647}
2648
2649#[derive(Debug, Deserialize, Default)]
2650struct SuggestCoverageQuery {
2651    path: Option<String>,
2652}
2653
2654async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
2655    const CANDIDATES: &[&str] = &[
2656        // LCOV — cargo-llvm-cov, gcov, lcov
2657        "coverage/lcov.info",
2658        "lcov.info",
2659        "target/llvm-cov/lcov.info",
2660        "target/coverage/lcov.info",
2661        "target/debug/coverage/lcov.info",
2662        "coverage/coverage.lcov",
2663        "build/coverage/lcov.info",
2664        "reports/lcov.info",
2665        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
2666        "coverage.xml",
2667        "coverage/coverage.xml",
2668        "target/site/cobertura/coverage.xml",
2669        "build/reports/coverage/coverage.xml",
2670        // JaCoCo XML — Gradle, Maven JaCoCo plugin
2671        "target/site/jacoco/jacoco.xml",
2672        "build/reports/jacoco/test/jacocoTestReport.xml",
2673        "build/reports/jacoco/jacocoTestReport.xml",
2674        "build/jacoco/jacoco.xml",
2675    ];
2676    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
2677    let found = CANDIDATES
2678        .iter()
2679        .map(|rel| root.join(rel))
2680        .find(|p| p.is_file())
2681        .map(|p| display_path(&p));
2682
2683    let (tool, hint) = detect_coverage_tool(&root);
2684    Json(serde_json::json!({ "found": found, "tool": tool, "hint": hint }))
2685}
2686
2687/// Inspect the project root for known build/package files and return the most likely coverage
2688/// tool name and the shell command needed to generate a coverage file.
2689fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
2690    if root.join("Cargo.toml").is_file() {
2691        return (
2692            Some("cargo-llvm-cov"),
2693            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
2694        );
2695    }
2696    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
2697        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
2698    }
2699    if root.join("pom.xml").is_file() {
2700        return (Some("jacoco"), Some("mvn test jacoco:report"));
2701    }
2702    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
2703        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
2704    }
2705    (None, None)
2706}
2707
2708/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
2709#[allow(clippy::result_large_err)]
2710fn validate_server_scan_path(
2711    config: &sloc_config::AppConfig,
2712    resolved_path: &Path,
2713    csp_nonce: &str,
2714) -> Result<(), Response> {
2715    if config.discovery.allowed_scan_roots.is_empty() {
2716        let template = ErrorTemplate {
2717            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
2718                      Set allowed_scan_roots in the server config to permit scanning."
2719                .to_string(),
2720            last_report_url: None,
2721            last_report_label: None,
2722            csp_nonce: csp_nonce.to_owned(),
2723        };
2724        return Err((
2725            StatusCode::FORBIDDEN,
2726            Html(
2727                template
2728                    .render()
2729                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
2730            ),
2731        )
2732            .into_response());
2733    }
2734    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
2735    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2736        fs::canonicalize(root)
2737            .ok()
2738            .is_some_and(|r| canonical.starts_with(&r))
2739    });
2740    if !allowed {
2741        tracing::warn!(event = "path_rejected", path = %canonical.display(),
2742            "Scan path not in allowed_scan_roots");
2743        let template = ErrorTemplate {
2744            message: "The requested path is not within an allowed scan directory.".to_string(),
2745            last_report_url: None,
2746            last_report_label: None,
2747            csp_nonce: csp_nonce.to_owned(),
2748        };
2749        return Err((
2750            StatusCode::FORBIDDEN,
2751            Html(
2752                template
2753                    .render()
2754                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
2755            ),
2756        )
2757            .into_response());
2758    }
2759    Ok(())
2760}
2761
2762/// Exclude the output directory from scanning so artifacts don't pollute counts.
2763fn apply_output_dir_exclusions(
2764    config: &mut sloc_config::AppConfig,
2765    project_path: &str,
2766    raw_output_dir: &str,
2767) {
2768    let project_root = resolve_input_path(project_path);
2769    let raw_out = raw_output_dir.trim();
2770    let resolved_out = if raw_out.is_empty() {
2771        project_root.join("sloc")
2772    } else if Path::new(raw_out).is_absolute() {
2773        PathBuf::from(raw_out)
2774    } else {
2775        workspace_root().join(raw_out)
2776    };
2777    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
2778        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
2779            let dir = first.to_string();
2780            if !config.discovery.excluded_directories.contains(&dir) {
2781                config.discovery.excluded_directories.push(dir);
2782            }
2783        }
2784    }
2785    if !config
2786        .discovery
2787        .excluded_directories
2788        .iter()
2789        .any(|d| d == "sloc")
2790    {
2791        config
2792            .discovery
2793            .excluded_directories
2794            .push("sloc".to_string());
2795    }
2796}
2797
2798/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
2799const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
2800    ScanSummarySnapshot {
2801        files_analyzed: run.summary_totals.files_analyzed,
2802        files_skipped: run.summary_totals.files_skipped,
2803        total_physical_lines: run.summary_totals.total_physical_lines,
2804        code_lines: run.summary_totals.code_lines,
2805        comment_lines: run.summary_totals.comment_lines,
2806        blank_lines: run.summary_totals.blank_lines,
2807        functions: run.summary_totals.functions,
2808        classes: run.summary_totals.classes,
2809        variables: run.summary_totals.variables,
2810        imports: run.summary_totals.imports,
2811        test_count: run.summary_totals.test_count,
2812    }
2813}
2814
2815/// Build the `RegistryEntry` for the just-completed scan run.
2816pub(crate) fn build_run_registry_entry(
2817    run: &AnalysisRun,
2818    run_id: &str,
2819    project_label: &str,
2820    artifacts: &RunArtifacts,
2821) -> RegistryEntry {
2822    RegistryEntry {
2823        run_id: run_id.to_owned(),
2824        timestamp_utc: run.tool.timestamp_utc,
2825        project_label: project_label.to_owned(),
2826        input_roots: run.input_roots.clone(),
2827        json_path: artifacts.json_path.clone(),
2828        html_path: artifacts.html_path.clone(),
2829        pdf_path: artifacts.pdf_path.clone(),
2830        summary: summary_snapshot_from_run(run),
2831        git_branch: run.git_branch.clone(),
2832        git_commit: run.git_commit_short.clone(),
2833        git_author: run.git_commit_author.clone(),
2834        git_tags: run.git_tags.clone(),
2835        git_nearest_tag: run.git_nearest_tag.clone(),
2836        git_commit_date: run.git_commit_date.clone(),
2837    }
2838}
2839
2840/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
2841fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
2842    if let Some(policy) = form.mixed_line_policy {
2843        config.analysis.mixed_line_policy = policy;
2844    }
2845    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
2846    config.analysis.generated_file_detection =
2847        form.generated_file_detection.as_deref() != Some("disabled");
2848    config.analysis.minified_file_detection =
2849        form.minified_file_detection.as_deref() != Some("disabled");
2850    config.analysis.vendor_directory_detection =
2851        form.vendor_directory_detection.as_deref() != Some("disabled");
2852    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
2853    if let Some(binary_behavior) = form.binary_file_behavior {
2854        config.analysis.binary_file_behavior = binary_behavior;
2855    }
2856    if let Some(report_title) = form.report_title.as_deref() {
2857        let trimmed = report_title.trim();
2858        if !trimmed.is_empty() {
2859            config.reporting.report_title = trimmed.to_string();
2860        }
2861    }
2862    if let Some(hf) = form.report_header_footer.as_deref() {
2863        let trimmed = hf.trim();
2864        config.reporting.report_header_footer = if trimmed.is_empty() {
2865            None
2866        } else {
2867            Some(trimmed.to_string())
2868        };
2869    }
2870    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
2871    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
2872    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
2873    if let Some(cov) = &form.coverage_file {
2874        let trimmed = cov.trim();
2875        if !trimmed.is_empty() {
2876            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
2877        }
2878    }
2879}
2880
2881/// Fire-and-forget: generate the PDF in a background task if one is pending.
2882fn spawn_pdf_background(pending_pdf: PendingPdf) {
2883    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
2884        tokio::spawn(async move {
2885            let result = tokio::task::spawn_blocking(move || {
2886                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
2887                if cleanup_src {
2888                    let _ = fs::remove_file(&pdf_src);
2889                }
2890                r
2891            })
2892            .await;
2893            match result {
2894                Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
2895                Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
2896                Ok(Ok(())) => {}
2897            }
2898        });
2899    }
2900}
2901
2902/// Sum the code lines added in this comparison (new + grown files).
2903fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2904    cmp.file_deltas
2905        .iter()
2906        .map(|f| match f.status {
2907            FileChangeStatus::Added => f.current_code,
2908            FileChangeStatus::Modified => f.code_delta.max(0),
2909            _ => 0,
2910        })
2911        .sum()
2912}
2913
2914/// Sum the code lines removed in this comparison (deleted + shrunk files).
2915fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2916    cmp.file_deltas
2917        .iter()
2918        .map(|f| match f.status {
2919            FileChangeStatus::Removed => f.baseline_code,
2920            FileChangeStatus::Modified => (-f.code_delta).max(0),
2921            _ => 0,
2922        })
2923        .sum()
2924}
2925
2926/// Build one `SubmoduleRow`, optionally generating and persisting a sub-report HTML file.
2927fn build_submodule_row(
2928    s: &sloc_core::SubmoduleSummary,
2929    run: &AnalysisRun,
2930    run_id: &str,
2931    run_dir: &Path,
2932    generate_html: bool,
2933) -> SubmoduleRow {
2934    let safe = sanitize_project_label(&s.name);
2935    let artifact_key = format!("sub_{safe}");
2936    let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
2937        let parent_path = run
2938            .input_roots
2939            .first()
2940            .map_or("", std::string::String::as_str);
2941        let sub_run = build_sub_run(run, s, parent_path);
2942        render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
2943            let path = run_dir.join(format!("{artifact_key}.html"));
2944            if fs::write(&path, sub_html.as_bytes()).is_ok() {
2945                Some(format!("/runs/{artifact_key}/{run_id}"))
2946            } else {
2947                None
2948            }
2949        })
2950    } else {
2951        None
2952    };
2953    SubmoduleRow {
2954        name: s.name.clone(),
2955        relative_path: s.relative_path.clone(),
2956        files_analyzed: s.files_analyzed,
2957        code_lines: s.code_lines,
2958        comment_lines: s.comment_lines,
2959        blank_lines: s.blank_lines,
2960        total_physical_lines: s.total_physical_lines,
2961        html_url,
2962    }
2963}
2964
2965// Immediately returns a wait page and runs the analysis in a background tokio task.
2966// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
2967#[allow(clippy::too_many_lines)]
2968#[allow(clippy::similar_names)]
2969async fn analyze_handler(
2970    // NOSONAR(rust:S3776)
2971    State(state): State<AppState>,
2972    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2973    Form(form): Form<AnalyzeForm>,
2974) -> impl IntoResponse {
2975    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
2976        let template = ErrorTemplate {
2977            message: "Server is busy — too many concurrent analyses. Please try again in a moment."
2978                .to_string(),
2979            last_report_url: None,
2980            last_report_label: None,
2981            csp_nonce: csp_nonce.clone(),
2982        };
2983        return (
2984            StatusCode::SERVICE_UNAVAILABLE,
2985            Html(
2986                template
2987                    .render()
2988                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
2989            ),
2990        )
2991            .into_response();
2992    };
2993
2994    let mut config = state.base_config.clone();
2995
2996    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
2997    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
2998    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
2999
3000    if !is_git_mode {
3001        let resolved_path = resolve_input_path(&form.path);
3002        if state.server_mode {
3003            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
3004                return resp;
3005            }
3006        }
3007        config.discovery.root_paths = vec![resolved_path];
3008    }
3009
3010    apply_form_to_config(&mut config, &form);
3011    apply_output_dir_exclusions(
3012        &mut config,
3013        &form.path,
3014        form.output_dir.as_deref().unwrap_or(""),
3015    );
3016
3017    // Generate a wait_id now (before spawning) so the client can poll for status.
3018    let wait_id = uuid::Uuid::new_v4().to_string();
3019    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
3020
3021    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
3022    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
3023
3024    // Clone everything the background task needs before moving into the spawn.
3025    let project_path_bg = form.path.clone();
3026    let output_dir_bg = form.output_dir.clone();
3027    let git_repo_bg = form.git_repo.clone().filter(|s| !s.is_empty());
3028    let git_ref_bg = form.git_ref.clone().filter(|s| !s.is_empty());
3029    let generate_html_bg = form.generate_html.is_some();
3030    let generate_pdf_bg = form.generate_pdf.is_some();
3031    let clones_dir = state.git_clones_dir.clone();
3032    let wait_id_bg = wait_id.clone();
3033    let state_bg = state.clone();
3034    let cancel_bg = Arc::clone(&cancel_token);
3035
3036    {
3037        let mut runs = state.async_runs.lock().await;
3038        runs.insert(
3039            wait_id.clone(),
3040            AsyncRunState::Running {
3041                started_at: std::time::Instant::now(),
3042                cancel_token,
3043            },
3044        );
3045    }
3046
3047    tokio::spawn(async move {
3048        // Hold the permit for the lifetime of the background task.
3049        let _permit = sem_permit;
3050
3051        // Clone before moving into spawn_blocking so we can use them again afterwards.
3052        let git_repo_sb = git_repo_bg.clone();
3053        let git_ref_sb = git_ref_bg.clone();
3054        let cancel_sb = Arc::clone(&cancel_bg);
3055        let analysis_result =
3056            tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
3057                if let (Some(repo), Some(refname)) = (&git_repo_sb, &git_ref_sb) {
3058                    let dest = git_clone_dest(repo, &clones_dir);
3059                    sloc_git::clone_or_fetch(repo, &dest)?;
3060                    let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3061                    sloc_git::create_worktree(&dest, refname, &wt)?;
3062                    config.discovery.root_paths = vec![wt.clone()];
3063                    let run = analyze(&config, "serve", Some(&cancel_sb));
3064                    let _ = sloc_git::destroy_worktree(&dest, &wt);
3065                    let mut run = run?;
3066                    if run.git_branch.is_none() {
3067                        run.git_branch = Some(refname.clone());
3068                    }
3069                    let html = render_html(&run)?;
3070                    return Ok((run, html));
3071                }
3072                let run = analyze(&config, "serve", Some(&cancel_sb))?;
3073                let html = render_html(&run)?;
3074                Ok((run, html))
3075            })
3076            .await
3077            .map_err(|err| anyhow::anyhow!(err.to_string()))
3078            .and_then(|result| result);
3079
3080        // If cancelled while running, discard results and mark as cancelled.
3081        if cancel_bg.load(std::sync::atomic::Ordering::Relaxed) {
3082            let mut runs = state_bg.async_runs.lock().await;
3083            // Only overwrite if still Running (don't clobber a Complete that snuck in).
3084            if matches!(
3085                runs.get(&wait_id_bg),
3086                Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
3087            ) {
3088                runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
3089            }
3090            drop(runs);
3091            return;
3092        }
3093
3094        let (run, report_html) = match analysis_result {
3095            Ok(v) => v,
3096            Err(err) => {
3097                // Distinguish user-cancelled from real failure.
3098                let message = if err.to_string().contains("analysis cancelled") {
3099                    let mut runs = state_bg.async_runs.lock().await;
3100                    runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
3101                    drop(runs);
3102                    return;
3103                } else {
3104                    "Analysis failed. Check that the path exists and is readable.".to_string()
3105                };
3106                eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
3107                let mut runs = state_bg.async_runs.lock().await;
3108                runs.insert(wait_id_bg.clone(), AsyncRunState::Failed { message });
3109                drop(runs);
3110                return;
3111            }
3112        };
3113
3114        let run_id = run.tool.run_id.clone();
3115        tracing::info!(event = "scan_complete", run_id = %run_id,
3116            path = %project_path_bg, files = run.summary_totals.files_analyzed,
3117            "Analysis finished");
3118
3119        let prev_entry: Option<RegistryEntry> = {
3120            let reg = state_bg.registry.lock().await;
3121            reg.entries_for_roots(&run.input_roots)
3122                .into_iter()
3123                .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3124                .cloned()
3125        };
3126
3127        let scan_delta = prev_entry.as_ref().and_then(|prev| {
3128            prev.json_path
3129                .as_ref()
3130                .and_then(|p| read_json(p).ok())
3131                .map(|prev_run| compute_delta(&prev_run, &run))
3132        });
3133        let prev_scan_count: usize = {
3134            let reg = state_bg.registry.lock().await;
3135            reg.entries_for_roots(&run.input_roots)
3136                .iter()
3137                .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3138                .count()
3139        };
3140
3141        let output_root = resolve_output_root(output_dir_bg.as_deref());
3142
3143        let project_label = if let (Some(repo), Some(refname)) = (
3144            git_repo_bg.as_deref().filter(|s| !s.is_empty()),
3145            git_ref_bg.as_deref().filter(|s| !s.is_empty()),
3146        ) {
3147            let repo_name = repo
3148                .trim_end_matches('/')
3149                .trim_end_matches(".git")
3150                .rsplit('/')
3151                .next()
3152                .unwrap_or("repo");
3153            sanitize_project_label(&format!("{repo_name}_{refname}"))
3154        } else {
3155            sanitize_project_label(&project_path_bg)
3156        };
3157        let run_dir = output_root.join(format!("{project_label}_{run_id}"));
3158        let file_stem = {
3159            let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
3160            if commit.is_empty() {
3161                project_label.clone()
3162            } else {
3163                format!("{project_label}_{commit}")
3164            }
3165        };
3166
3167        let result_context = RunResultContext {
3168            prev_entry: prev_entry.clone(),
3169            prev_scan_count,
3170            project_path: project_path_bg.clone(),
3171        };
3172
3173        let artifact_result = persist_run_artifacts(
3174            &run,
3175            &report_html,
3176            &run_dir,
3177            true,
3178            generate_html_bg,
3179            generate_pdf_bg,
3180            &run.effective_configuration.reporting.report_title,
3181            &file_stem,
3182            result_context,
3183        );
3184
3185        let (artifacts, pending_pdf) = match artifact_result {
3186            Ok(v) => v,
3187            Err(err) => {
3188                eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
3189                let mut runs = state_bg.async_runs.lock().await;
3190                runs.insert(
3191                    wait_id_bg.clone(),
3192                    AsyncRunState::Failed {
3193                        message: "Failed to save report artifacts. Check available disk space."
3194                            .to_string(),
3195                    },
3196                );
3197                drop(runs);
3198                return;
3199            }
3200        };
3201
3202        {
3203            let mut map = state_bg.artifacts.lock().await;
3204            map.insert(run_id.clone(), artifacts.clone());
3205        }
3206
3207        {
3208            let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
3209            let mut reg = state_bg.registry.lock().await;
3210            reg.add_entry(entry);
3211            let _ = reg.save(&state_bg.registry_path);
3212        }
3213
3214        if let Some(ref cfg_path) = artifacts.scan_config_path {
3215            let policy_str =
3216                serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
3217                    .ok()
3218                    .and_then(|v| v.as_str().map(String::from))
3219                    .unwrap_or_else(|| "code_only".to_string());
3220            let behavior_str =
3221                serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
3222                    .ok()
3223                    .and_then(|v| v.as_str().map(String::from))
3224                    .unwrap_or_else(|| "skip".to_string());
3225            let scan_cfg = ScanConfig {
3226                oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
3227                path: project_path_bg.clone(),
3228                include_globs: run
3229                    .effective_configuration
3230                    .discovery
3231                    .include_globs
3232                    .join("\n"),
3233                exclude_globs: run
3234                    .effective_configuration
3235                    .discovery
3236                    .exclude_globs
3237                    .join("\n"),
3238                submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
3239                mixed_line_policy: policy_str,
3240                python_docstrings_as_comments: run
3241                    .effective_configuration
3242                    .analysis
3243                    .python_docstrings_as_comments,
3244                generated_file_detection: run
3245                    .effective_configuration
3246                    .analysis
3247                    .generated_file_detection,
3248                minified_file_detection: run
3249                    .effective_configuration
3250                    .analysis
3251                    .minified_file_detection,
3252                vendor_directory_detection: run
3253                    .effective_configuration
3254                    .analysis
3255                    .vendor_directory_detection,
3256                include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
3257                binary_file_behavior: behavior_str,
3258                output_dir: output_dir_bg.clone().unwrap_or_default(),
3259                report_title: run.effective_configuration.reporting.report_title.clone(),
3260                generate_html: generate_html_bg,
3261                generate_pdf: generate_pdf_bg,
3262            };
3263            if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3264                let _ = std::fs::write(cfg_path, json);
3265            }
3266        }
3267
3268        spawn_pdf_background(pending_pdf);
3269
3270        // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
3271        let mut runs = state_bg.async_runs.lock().await;
3272        runs.insert(
3273            wait_id_bg.clone(),
3274            AsyncRunState::Complete {
3275                run_id: run_id.clone(),
3276            },
3277        );
3278        drop(runs);
3279
3280        // Submodule sub-reports are rendered synchronously above inside background task.
3281        let _ = scan_delta;
3282    });
3283
3284    let template = ScanWaitTemplate {
3285        version: env!("CARGO_PKG_VERSION"),
3286        wait_id_json,
3287        project_path: form.path.clone(),
3288        csp_nonce,
3289    };
3290    let html = template
3291        .render()
3292        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
3293    let mut response = Html(html).into_response();
3294    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
3295        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
3296            response.headers_mut().insert(name, val);
3297        }
3298    }
3299    response
3300}
3301
3302// ── Async scan status + result handlers ──────────────────────────────────────
3303
3304#[derive(Serialize)]
3305#[serde(tag = "state", rename_all = "snake_case")]
3306enum AsyncRunStatusResponse {
3307    Running { elapsed_secs: u64 },
3308    Complete { run_id: String },
3309    Failed { message: String },
3310    Cancelled,
3311}
3312
3313async fn async_run_status_handler(
3314    State(state): State<AppState>,
3315    AxumPath(wait_id): AxumPath<String>,
3316) -> Response {
3317    // wait_id comes from our own UUID generator; reject any structurally malformed value.
3318    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3319        return StatusCode::BAD_REQUEST.into_response();
3320    }
3321    let run_state = {
3322        let runs = state.async_runs.lock().await;
3323        runs.get(&wait_id).cloned()
3324    };
3325    match run_state {
3326        None => StatusCode::NOT_FOUND.into_response(),
3327        Some(AsyncRunState::Running { started_at, .. }) => {
3328            // Treat runs older than 2 h as timed out (analysis should finish well under that).
3329            if started_at.elapsed() > std::time::Duration::from_hours(2) {
3330                let mut runs = state.async_runs.lock().await;
3331                runs.insert(
3332                    wait_id,
3333                    AsyncRunState::Failed {
3334                        message: "Analysis timed out after 2 hours.".to_string(),
3335                    },
3336                );
3337                drop(runs);
3338                return Json(AsyncRunStatusResponse::Failed {
3339                    message: "Analysis timed out after 2 hours.".to_string(),
3340                })
3341                .into_response();
3342            }
3343            Json(AsyncRunStatusResponse::Running {
3344                elapsed_secs: started_at.elapsed().as_secs(),
3345            })
3346            .into_response()
3347        }
3348        Some(AsyncRunState::Complete { run_id }) => {
3349            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
3350        }
3351        Some(AsyncRunState::Failed { message }) => {
3352            Json(AsyncRunStatusResponse::Failed { message }).into_response()
3353        }
3354        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
3355    }
3356}
3357
3358async fn cancel_run_handler(
3359    State(state): State<AppState>,
3360    AxumPath(wait_id): AxumPath<String>,
3361) -> Response {
3362    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3363        return StatusCode::BAD_REQUEST.into_response();
3364    }
3365    let mut runs = state.async_runs.lock().await;
3366    let resp = match runs.get(&wait_id) {
3367        Some(AsyncRunState::Running { cancel_token, .. }) => {
3368            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
3369            runs.insert(wait_id, AsyncRunState::Cancelled);
3370            StatusCode::OK.into_response()
3371        }
3372        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
3373        _ => StatusCode::NOT_FOUND.into_response(),
3374    };
3375    drop(runs);
3376    resp
3377}
3378
3379async fn async_run_result_handler(
3380    State(state): State<AppState>,
3381    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3382    AxumPath(run_id): AxumPath<String>,
3383) -> Response {
3384    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
3385        return StatusCode::BAD_REQUEST.into_response();
3386    }
3387
3388    let artifacts = {
3389        let map = state.artifacts.lock().await;
3390        map.get(&run_id).cloned()
3391    };
3392    let artifacts = if let Some(a) = artifacts {
3393        a
3394    } else {
3395        let reg = state.registry.lock().await;
3396        if let Some(entry) = reg.find_by_run_id(&run_id) {
3397            recover_artifacts_from_registry(entry)
3398        } else {
3399            let html = ErrorTemplate {
3400                message: format!(
3401                    "Report not found. Run ID {} is not in the scan history.",
3402                    &run_id[..run_id.len().min(8)]
3403                ),
3404                last_report_url: Some("/view-reports".to_string()),
3405                last_report_label: Some("View Reports".to_string()),
3406                csp_nonce: csp_nonce.clone(),
3407            }
3408            .render()
3409            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3410            return (StatusCode::NOT_FOUND, Html(html)).into_response();
3411        }
3412    };
3413
3414    let json_path = if let Some(p) = &artifacts.json_path {
3415        p.clone()
3416    } else {
3417        let html = ErrorTemplate {
3418            message: "JSON result was not saved for this run.".to_string(),
3419            last_report_url: Some("/view-reports".to_string()),
3420            last_report_label: Some("View Reports".to_string()),
3421            csp_nonce: csp_nonce.clone(),
3422        }
3423        .render()
3424        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
3425        return (StatusCode::NOT_FOUND, Html(html)).into_response();
3426    };
3427
3428    let Ok(run) = read_json(&json_path) else {
3429        let folder_hint = json_path
3430            .parent()
3431            .map(|p| p.display().to_string())
3432            .unwrap_or_default();
3433        let redirect_url = format!("/runs/result/{run_id}");
3434        return missing_scan_relocate_response(
3435            &format!(
3436                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
3437                 deleted. Browse to the folder containing your scan output to reconnect it.",
3438                json_path.display()
3439            ),
3440            &run_id,
3441            &folder_hint,
3442            &redirect_url,
3443            state.server_mode,
3444            &csp_nonce,
3445        );
3446    };
3447
3448    let confluence_configured = {
3449        let store = state.confluence.lock().await;
3450        store.is_configured()
3451    };
3452
3453    render_result_page(&run, &artifacts, &run_id, &csp_nonce, confluence_configured)
3454}
3455
3456#[allow(clippy::too_many_lines)]
3457#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
3458fn render_result_page(
3459    // NOSONAR(rust:S3776)
3460    run: &AnalysisRun,
3461    artifacts: &RunArtifacts,
3462    run_id: &str,
3463    csp_nonce: &str,
3464    confluence_configured: bool,
3465) -> Response {
3466    let ctx = &artifacts.result_context;
3467    let prev_entry = &ctx.prev_entry;
3468    let prev_scan_count = ctx.prev_scan_count;
3469    let project_path = &ctx.project_path;
3470
3471    let scan_delta = prev_entry.as_ref().and_then(|prev| {
3472        prev.json_path
3473            .as_ref()
3474            .and_then(|p| read_json(p).ok())
3475            .map(|prev_run| compute_delta(&prev_run, run))
3476    });
3477
3478    let files_analyzed = run.per_file_records.len() as u64;
3479    let files_skipped = run.skipped_file_records.len() as u64;
3480    let physical_lines = run
3481        .totals_by_language
3482        .iter()
3483        .map(|r| r.total_physical_lines)
3484        .sum::<u64>();
3485    let code_lines = run
3486        .totals_by_language
3487        .iter()
3488        .map(|r| r.code_lines)
3489        .sum::<u64>();
3490    let comment_lines = run
3491        .totals_by_language
3492        .iter()
3493        .map(|r| r.comment_lines)
3494        .sum::<u64>();
3495    let blank_lines = run
3496        .totals_by_language
3497        .iter()
3498        .map(|r| r.blank_lines)
3499        .sum::<u64>();
3500    let mixed_lines = run
3501        .totals_by_language
3502        .iter()
3503        .map(|r| r.mixed_lines_separate)
3504        .sum::<u64>();
3505    let functions = run
3506        .totals_by_language
3507        .iter()
3508        .map(|r| r.functions)
3509        .sum::<u64>();
3510    let classes = run
3511        .totals_by_language
3512        .iter()
3513        .map(|r| r.classes)
3514        .sum::<u64>();
3515    let variables = run
3516        .totals_by_language
3517        .iter()
3518        .map(|r| r.variables)
3519        .sum::<u64>();
3520    let imports = run
3521        .totals_by_language
3522        .iter()
3523        .map(|r| r.imports)
3524        .sum::<u64>();
3525
3526    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
3527    let prev_fa = prev_sum.map(|s| s.files_analyzed);
3528    let prev_fs = prev_sum.map(|s| s.files_skipped);
3529    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
3530    let prev_cl = prev_sum.map(|s| s.code_lines);
3531    let prev_cml = prev_sum.map(|s| s.comment_lines);
3532    let prev_bl = prev_sum.map(|s| s.blank_lines);
3533    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
3534    let prev_fa_str = fmt_prev(prev_fa);
3535    let prev_fs_str = fmt_prev(prev_fs);
3536    let prev_pl_str = fmt_prev(prev_pl);
3537    let prev_cl_str = fmt_prev(prev_cl);
3538    let prev_cml_str = fmt_prev(prev_cml);
3539    let prev_bl_str = fmt_prev(prev_bl);
3540    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
3541    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
3542    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
3543    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
3544    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
3545    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
3546    let delta_fa_class = delta_fa_class.to_string();
3547    let delta_fs_class = delta_fs_class.to_string();
3548    let delta_pl_class = delta_pl_class.to_string();
3549    let delta_cl_class = delta_cl_class.to_string();
3550    let delta_cml_class = delta_cml_class.to_string();
3551    let delta_bl_class = delta_bl_class.to_string();
3552
3553    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
3554    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
3555    let (delta_lines_net_str, delta_lines_net_class) =
3556        match (delta_lines_added, delta_lines_removed) {
3557            (Some(a), Some(r)) => {
3558                let net = a - r;
3559                (fmt_delta(net), delta_class(net).to_string())
3560            }
3561            _ => ("—".to_string(), "na".to_string()),
3562        };
3563
3564    let run_dir = artifacts.output_dir.clone();
3565    let git_branch = run.git_branch.clone();
3566    let git_commit = run.git_commit_short.clone();
3567    let git_author = run.git_commit_author.clone();
3568
3569    let template = ResultTemplate {
3570        version: env!("CARGO_PKG_VERSION"),
3571        report_title: run.effective_configuration.reporting.report_title.clone(),
3572        project_path: project_path.clone(),
3573        output_dir: display_path(&artifacts.output_dir),
3574        run_id: run_id.to_owned(),
3575        files_analyzed,
3576        files_skipped,
3577        physical_lines,
3578        code_lines,
3579        comment_lines,
3580        blank_lines,
3581        mixed_lines,
3582        functions,
3583        classes,
3584        variables,
3585        imports,
3586        html_url: artifacts
3587            .html_path
3588            .as_ref()
3589            .map(|_| format!("/runs/html/{run_id}")),
3590        pdf_url: artifacts
3591            .pdf_path
3592            .as_ref()
3593            .map(|_| format!("/runs/pdf/{run_id}")),
3594        json_url: artifacts
3595            .json_path
3596            .as_ref()
3597            .map(|_| format!("/runs/json/{run_id}")),
3598        html_download_url: artifacts
3599            .html_path
3600            .as_ref()
3601            .map(|_| format!("/runs/html/{run_id}?download=1")),
3602        pdf_download_url: artifacts
3603            .pdf_path
3604            .as_ref()
3605            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
3606        json_download_url: artifacts
3607            .json_path
3608            .as_ref()
3609            .map(|_| format!("/runs/json/{run_id}?download=1")),
3610        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
3611        pdf_path: artifacts.pdf_path.as_ref().map(|p| display_path(p)),
3612        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
3613        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
3614        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
3615        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
3616        prev_fa_str,
3617        prev_fs_str,
3618        prev_pl_str,
3619        prev_cl_str,
3620        prev_cml_str,
3621        prev_bl_str,
3622        delta_fa_str,
3623        delta_fa_class,
3624        delta_fs_str,
3625        delta_fs_class,
3626        delta_pl_str,
3627        delta_pl_class,
3628        delta_cl_str,
3629        delta_cl_class,
3630        delta_cml_str,
3631        delta_cml_class,
3632        delta_bl_str,
3633        delta_bl_class,
3634        delta_lines_added,
3635        delta_lines_removed,
3636        delta_lines_net_str,
3637        delta_lines_net_class,
3638        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
3639        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
3640        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
3641        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
3642        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
3643            d.file_deltas
3644                .iter()
3645                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
3646                .map(|f| {
3647                    #[allow(clippy::cast_sign_loss)]
3648                    let n = f.current_code as u64;
3649                    n
3650                })
3651                .sum()
3652        }),
3653        git_branch,
3654        git_commit,
3655        git_author,
3656        current_scan_number: prev_scan_count + 1,
3657        prev_scan_count,
3658        submodule_rows: run
3659            .submodule_summaries
3660            .iter()
3661            .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
3662            .collect(),
3663        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
3664        scan_config_url: format!("/runs/scan-config/{run_id}"),
3665        lang_chart_json: {
3666            let entries: Vec<String> = run
3667                .totals_by_language
3668                .iter()
3669                .take(12)
3670                .map(|l| {
3671                    let name = l
3672                        .language
3673                        .display_name()
3674                        .replace('\\', "\\\\")
3675                        .replace('"', "\\\"");
3676                    format!(
3677                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
3678                        name,
3679                        l.code_lines,
3680                        l.comment_lines,
3681                        l.blank_lines,
3682                        l.functions,
3683                        l.classes,
3684                        l.variables,
3685                        l.imports,
3686                        l.files,
3687                    )
3688                })
3689                .collect();
3690            format!("[{}]", entries.join(","))
3691        },
3692        scatter_chart_json: {
3693            let entries: Vec<String> = run
3694                .totals_by_language
3695                .iter()
3696                .map(|l| {
3697                    let name = l
3698                        .language
3699                        .display_name()
3700                        .replace('\\', "\\\\")
3701                        .replace('"', "\\\"");
3702                    format!(
3703                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
3704                        name, l.files, l.code_lines, l.total_physical_lines,
3705                    )
3706                })
3707                .collect();
3708            format!("[{}]", entries.join(","))
3709        },
3710        semantic_chart_json: {
3711            let entries: Vec<String> = run
3712                .totals_by_language
3713                .iter()
3714                .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
3715                .map(|l| {
3716                    let name = l
3717                        .language
3718                        .display_name()
3719                        .replace('\\', "\\\\")
3720                        .replace('"', "\\\"");
3721                    format!(
3722                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
3723                        name, l.functions, l.classes, l.variables, l.imports,
3724                    )
3725                })
3726                .collect();
3727            format!("[{}]", entries.join(","))
3728        },
3729        submodule_chart_json: {
3730            let entries: Vec<String> = run
3731                .submodule_summaries
3732                .iter()
3733                .map(|s| {
3734                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
3735                    format!(
3736                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
3737                        name,
3738                        s.code_lines,
3739                        s.comment_lines,
3740                        s.blank_lines,
3741                        s.total_physical_lines,
3742                        s.files_analyzed,
3743                    )
3744                })
3745                .collect();
3746            format!("[{}]", entries.join(","))
3747        },
3748        has_submodule_data: !run.submodule_summaries.is_empty(),
3749        has_semantic_data: run
3750            .totals_by_language
3751            .iter()
3752            .any(|l| l.functions > 0 || l.classes > 0),
3753        csp_nonce: csp_nonce.to_owned(),
3754        confluence_configured,
3755        report_header_footer: run
3756            .effective_configuration
3757            .reporting
3758            .report_header_footer
3759            .clone(),
3760    };
3761
3762    Html(
3763        template
3764            .render()
3765            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
3766    )
3767    .into_response()
3768}
3769
3770fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
3771    let slug: String = report_title
3772        .chars()
3773        .map(|c| {
3774            if c.is_alphanumeric() || c == '-' {
3775                c.to_ascii_lowercase()
3776            } else {
3777                '_'
3778            }
3779        })
3780        .collect::<String>()
3781        .split('_')
3782        .filter(|s| !s.is_empty())
3783        .collect::<Vec<_>>()
3784        .join("_");
3785
3786    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
3787
3788    if slug.is_empty() {
3789        format!("report_{short_id}.pdf")
3790    } else {
3791        format!("{slug}_{short_id}.pdf")
3792    }
3793}
3794
3795/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
3796/// Clients poll this to update the button state without page reloads.
3797async fn pdf_status_handler(
3798    State(state): State<AppState>,
3799    AxumPath(run_id): AxumPath<String>,
3800) -> Response {
3801    let pdf_path = {
3802        let registry = state.artifacts.lock().await;
3803        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
3804    };
3805    let pdf_path = if pdf_path.is_some() {
3806        pdf_path
3807    } else {
3808        let reg = state.registry.lock().await;
3809        reg.find_by_run_id(&run_id)
3810            .map(recover_artifacts_from_registry)
3811            .and_then(|a| a.pdf_path)
3812    };
3813    let ready = pdf_path.is_some_and(|p| p.exists());
3814    Json(serde_json::json!({"ready": ready})).into_response()
3815}
3816
3817/// Serve the HTML artifact for a run — view or download.
3818/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
3819/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
3820/// current-request Content-Security-Policy nonce check.
3821fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
3822    // Find the first nonce value that was baked in at render time.
3823    let Some(start) = html.find("nonce=\"") else {
3824        // Reports generated before nonce support was added have bare <style> and <script>
3825        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
3826        // the inline blocks — without it the browser blocks all CSS and JS.
3827        return html
3828            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
3829            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
3830    };
3831    let value_start = start + 7; // len(r#"nonce=""#) == 7
3832    let Some(end_offset) = html[value_start..].find('"') else {
3833        return html.to_owned();
3834    };
3835    let old_nonce = &html[value_start..value_start + end_offset];
3836    html.replace(
3837        &format!("nonce=\"{old_nonce}\""),
3838        &format!("nonce=\"{new_nonce}\""),
3839    )
3840}
3841
3842fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3843    match fs::read_to_string(path) {
3844        Ok(raw) => {
3845            // Patch the saved nonce so inline styles/scripts pass CSP.
3846            let content = patch_html_nonce(&raw, csp_nonce);
3847            if wants_download {
3848                (
3849                    [
3850                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
3851                        (
3852                            header::CONTENT_DISPOSITION,
3853                            "attachment; filename=report.html",
3854                        ),
3855                    ],
3856                    content,
3857                )
3858                    .into_response()
3859            } else {
3860                Html(content).into_response()
3861            }
3862        }
3863        Err(err) => {
3864            let filename = path.file_name().map_or_else(
3865                || "report.html".to_string(),
3866                |n| n.to_string_lossy().into_owned(),
3867            );
3868            let msg = format!(
3869                "HTML report '{filename}' could not be read.\n\n\
3870                 Error: {err}\n\n\
3871                 If you moved or renamed the output folder, the stored path is now stale. \
3872                 Use 'Open HTML folder' from the results page to browse the output directory."
3873            );
3874            let html = ErrorTemplate {
3875                message: msg,
3876                last_report_url: Some("/view-reports".to_string()),
3877                last_report_label: Some("View Reports".to_string()),
3878                csp_nonce: csp_nonce.to_owned(),
3879            }
3880            .render()
3881            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3882            (StatusCode::NOT_FOUND, Html(html)).into_response()
3883        }
3884    }
3885}
3886
3887/// Serve the PDF artifact for a run — inline or download.
3888fn serve_pdf_artifact(
3889    path: &Path,
3890    report_title: &str,
3891    run_id: &str,
3892    wants_download: bool,
3893    csp_nonce: &str,
3894) -> Response {
3895    match fs::read(path) {
3896        Ok(bytes) => {
3897            let filename = build_pdf_filename(report_title, run_id);
3898            let disposition = if wants_download {
3899                format!("attachment; filename=\"{filename}\"")
3900            } else {
3901                format!("inline; filename=\"{filename}\"")
3902            };
3903            (
3904                [
3905                    (header::CONTENT_TYPE, "application/pdf".to_string()),
3906                    (header::CONTENT_DISPOSITION, disposition),
3907                ],
3908                bytes,
3909            )
3910                .into_response()
3911        }
3912        Err(err) => {
3913            let filename = path.file_name().map_or_else(
3914                || "report.pdf".to_string(),
3915                |n| n.to_string_lossy().into_owned(),
3916            );
3917            let msg = format!(
3918                "PDF report '{filename}' could not be read.\n\n\
3919                 Error: {err}\n\n\
3920                 If you moved or renamed the output folder, the stored path is now stale. \
3921                 Use 'Open PDF folder' from the results page to browse the output directory."
3922            );
3923            let html = ErrorTemplate {
3924                message: msg,
3925                last_report_url: Some("/view-reports".to_string()),
3926                last_report_label: Some("View Reports".to_string()),
3927                csp_nonce: csp_nonce.to_owned(),
3928            }
3929            .render()
3930            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3931            (StatusCode::NOT_FOUND, Html(html)).into_response()
3932        }
3933    }
3934}
3935
3936/// Serve the JSON artifact for a run — view or download.
3937fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3938    match fs::read(path) {
3939        Ok(bytes) => {
3940            if wants_download {
3941                (
3942                    [
3943                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
3944                        (
3945                            header::CONTENT_DISPOSITION,
3946                            "attachment; filename=result.json",
3947                        ),
3948                    ],
3949                    bytes,
3950                )
3951                    .into_response()
3952            } else {
3953                (
3954                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
3955                    bytes,
3956                )
3957                    .into_response()
3958            }
3959        }
3960        Err(err) => {
3961            let filename = path.file_name().map_or_else(
3962                || "result.json".to_string(),
3963                |n| n.to_string_lossy().into_owned(),
3964            );
3965            let msg = format!(
3966                "JSON result '{filename}' could not be read.\n\n\
3967                 Error: {err}\n\n\
3968                 If you moved or renamed the output folder, the stored path is now stale. \
3969                 Use 'Open JSON folder' from the results page to browse the output directory."
3970            );
3971            let html = ErrorTemplate {
3972                message: msg,
3973                last_report_url: Some("/view-reports".to_string()),
3974                last_report_label: Some("View Reports".to_string()),
3975                csp_nonce: csp_nonce.to_owned(),
3976            }
3977            .render()
3978            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3979            (StatusCode::NOT_FOUND, Html(html)).into_response()
3980        }
3981    }
3982}
3983
3984/// Recover a `RunArtifacts` from the persisted registry for a run ID.
3985fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
3986    let output_dir = entry
3987        .html_path
3988        .as_ref()
3989        .or(entry.json_path.as_ref())
3990        .or(entry.pdf_path.as_ref())
3991        .and_then(|p| p.parent().map(PathBuf::from))
3992        .unwrap_or_default();
3993    // Recover pdf_path: use the persisted one, or look for report.pdf
3994    // adjacent to html/json if only the old entries lack it.
3995    let pdf_path = entry.pdf_path.clone().or_else(|| {
3996        let candidate = output_dir.join("report.pdf");
3997        candidate.exists().then_some(candidate)
3998    });
3999    RunArtifacts {
4000        output_dir: output_dir.clone(),
4001        html_path: entry.html_path.clone(),
4002        pdf_path,
4003        json_path: entry.json_path.clone(),
4004        scan_config_path: find_scan_config_in_dir(&output_dir),
4005        report_title: entry.project_label.clone(),
4006        result_context: RunResultContext::default(),
4007    }
4008}
4009
4010#[allow(clippy::too_many_lines)]
4011async fn artifact_handler(
4012    // NOSONAR(rust:S3776)
4013    State(state): State<AppState>,
4014    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4015    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
4016    Query(query): Query<ArtifactQuery>,
4017) -> Response {
4018    let artifact_set = {
4019        let registry = state.artifacts.lock().await;
4020        registry.get(&run_id).cloned()
4021    };
4022
4023    // Fall back to the persisted registry when the server was restarted and the
4024    // in-memory artifact map no longer holds the entry.
4025    let artifact_set = if let Some(a) = artifact_set {
4026        a
4027    } else {
4028        let reg = state.registry.lock().await;
4029        if let Some(entry) = reg.find_by_run_id(&run_id) {
4030            recover_artifacts_from_registry(entry)
4031        } else {
4032            let short_id = &run_id[..run_id.len().min(8)];
4033            let hint = if matches!(run_id.as_str(), "pdf" | "html" | "json" | "scan-config") {
4034                format!(
4035                    " The URL format appears to be reversed — \
4036                     the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
4037                     Use the View Reports page to navigate to your scan."
4038                )
4039            } else {
4040                " The report may have been deleted or the report directory moved. \
4041                 Use View Reports to browse your scan history."
4042                    .to_string()
4043            };
4044            let error_html = ErrorTemplate {
4045                message: format!(
4046                    "Report not found. \"{short_id}\" is not a recognized run ID.{hint}"
4047                ),
4048                last_report_url: Some("/view-reports".to_string()),
4049                last_report_label: Some("View Reports".to_string()),
4050                csp_nonce: csp_nonce.clone(),
4051            }
4052            .render()
4053            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4054            return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
4055        }
4056    };
4057
4058    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
4059
4060    match artifact.as_str() {
4061        "html" => {
4062            let Some(path) = artifact_set.html_path else {
4063                return StatusCode::NOT_FOUND.into_response();
4064            };
4065            serve_html_artifact(&path, wants_download, &csp_nonce)
4066        }
4067        "pdf" => {
4068            let Some(path) = artifact_set.pdf_path else {
4069                let msg = "PDF report was not generated for this run, or was not recorded in \
4070                           the scan registry. Re-run the analysis with PDF output enabled."
4071                    .to_string();
4072                let html = ErrorTemplate {
4073                    message: msg,
4074                    last_report_url: Some(format!("/runs/html/{run_id}")),
4075                    last_report_label: Some("View HTML Report".to_string()),
4076                    csp_nonce: csp_nonce.clone(),
4077                }
4078                .render()
4079                .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
4080                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4081            };
4082            // PDF path is recorded but the background task may still be writing it.
4083            // Return a self-refreshing "please wait" page rather than an error.
4084            if !path.exists() {
4085                let html = format!(
4086                    "<!doctype html><html lang=\"en\"><head>\
4087                     <meta charset=utf-8>\
4088                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
4089                     <meta http-equiv=\"refresh\" content=\"5\">\
4090                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
4091                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
4092                     <style nonce=\"{csp_nonce}\">\
4093                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
4094                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
4095                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
4096                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
4097                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
4098                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
4099                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
4100                     background:var(--bg);color:var(--text);}}\
4101                     .top-nav{{position:sticky;top:0;z-index:30;\
4102                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
4103                     border-bottom:1px solid rgba(255,255,255,0.12);\
4104                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
4105                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
4106                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
4107                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
4108                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
4109                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
4110                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
4111                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
4112                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
4113                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
4114                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
4115                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
4116                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
4117                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
4118                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
4119                     justify-content:center;min-height:38px;border-radius:999px;\
4120                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
4121                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
4122                     .theme-toggle .icon-sun{{display:none;}}\
4123                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
4124                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
4125                     .page{{max-width:1720px;margin:0 auto;padding:60px 24px;\
4126                     display:flex;align-items:center;justify-content:center;\
4127                     min-height:calc(100vh - 56px);}}\
4128                     .panel{{background:var(--surface);border:1px solid var(--line);\
4129                     border-radius:var(--radius);box-shadow:var(--shadow);\
4130                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
4131                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
4132                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
4133                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
4134                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
4135                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
4136                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
4137                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
4138                     min-height:42px;padding:0 20px;border-radius:14px;\
4139                     border:1px solid var(--line-strong);text-decoration:none;\
4140                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
4141                     .back-link:hover{{background:var(--line);}}\
4142                     </style></head>\
4143                     <body>\
4144                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
4145                       <a class=\"brand\" href=\"/\">\
4146                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
4147                         <div class=\"brand-copy\">\
4148                           <div class=\"brand-title\">OxideSLOC</div>\
4149                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
4150                         </div>\
4151                       </a>\
4152                       <div class=\"nav-right\">\
4153                         <a class=\"nav-pill\" href=\"/\">Home</a>\
4154                         <div class=\"nav-dropdown\">\
4155                           <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>\
4156                           <div class=\"nav-dropdown-menu\">\
4157                             <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>\
4158                           </div>\
4159                         </div>\
4160                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
4161                           <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>\
4162                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
4163                           <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>\
4164                         </button>\
4165                       </div>\
4166                     </div></div>\
4167                     <div class=\"page\"><div class=\"panel\">\
4168                       <div class=\"spin-ring\"></div>\
4169                       <h1>Generating PDF\u{2026}</h1>\
4170                       <p>The PDF is being rendered from the HTML report.<br>\
4171                       This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
4172                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
4173                     </div></div>\
4174                     <script nonce=\"{csp_nonce}\">\
4175                     (function(){{\
4176                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
4177                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
4178                       var t=document.getElementById(\"theme-toggle\");\
4179                       if(t)t.addEventListener(\"click\",function(){{\
4180                         var d=b.classList.toggle(\"dark-theme\");\
4181                         localStorage.setItem(k,d?\"dark\":\"light\");\
4182                       }});\
4183                     }})();\
4184                     </script>\
4185                     </body></html>"
4186                );
4187                return Html(html).into_response();
4188            }
4189            serve_pdf_artifact(
4190                &path,
4191                &artifact_set.report_title,
4192                &run_id,
4193                wants_download,
4194                &csp_nonce,
4195            )
4196        }
4197        "json" => {
4198            let Some(path) = artifact_set.json_path else {
4199                let msg = "JSON result was not generated for this run, or was not recorded in \
4200                           the scan registry. Re-run the analysis with JSON output enabled."
4201                    .to_string();
4202                let html = ErrorTemplate {
4203                    message: msg,
4204                    last_report_url: Some("/view-reports".to_string()),
4205                    last_report_label: Some("View Reports".to_string()),
4206                    csp_nonce: csp_nonce.clone(),
4207                }
4208                .render()
4209                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
4210                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4211            };
4212            serve_json_artifact(&path, wants_download, &csp_nonce)
4213        }
4214        "scan-config" => {
4215            let path = artifact_set
4216                .scan_config_path
4217                .as_deref()
4218                .map(std::path::Path::to_path_buf)
4219                .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
4220                .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
4221            fs::read(&path).map_or_else(
4222                |_| StatusCode::NOT_FOUND.into_response(),
4223                |bytes| {
4224                    (
4225                        [
4226                            (
4227                                header::CONTENT_TYPE,
4228                                "application/json; charset=utf-8".to_string(),
4229                            ),
4230                            (
4231                                header::CONTENT_DISPOSITION,
4232                                "attachment; filename=\"scan-config.json\"".to_string(),
4233                            ),
4234                        ],
4235                        bytes,
4236                    )
4237                        .into_response()
4238                },
4239            )
4240        }
4241        _ if artifact.starts_with("sub_") => {
4242            if artifact.len() > 128
4243                || !artifact
4244                    .chars()
4245                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4246            {
4247                return StatusCode::BAD_REQUEST.into_response();
4248            }
4249            let filename = format!("{artifact}.html");
4250            let path = artifact_set.output_dir.join(&filename);
4251            if !path.exists() {
4252                let html = ErrorTemplate {
4253                    message: format!(
4254                        "Sub-report '{artifact}' was not found in the run directory.\n\
4255                         Re-run the analysis with 'Detect and separate git submodules' \
4256                         and HTML output enabled."
4257                    ),
4258                    last_report_url: Some("/view-reports".to_string()),
4259                    last_report_label: Some("View Reports".to_string()),
4260                    csp_nonce: csp_nonce.clone(),
4261                }
4262                .render()
4263                .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
4264                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4265            }
4266            serve_html_artifact(&path, wants_download, &csp_nonce)
4267        }
4268        _ => StatusCode::NOT_FOUND.into_response(),
4269    }
4270}
4271
4272// ── History ───────────────────────────────────────────────────────────────────
4273
4274struct SubmoduleLinkRow {
4275    name: String,
4276    url: String,
4277}
4278
4279struct HistoryEntryRow {
4280    run_id: String,
4281    run_id_short: String,
4282    timestamp: String,
4283    timestamp_utc_ms: i64,
4284    project_label: String,
4285    project_path: String,
4286    files_analyzed: u64,
4287    files_skipped: u64,
4288    code_lines: u64,
4289    comment_lines: u64,
4290    blank_lines: u64,
4291    git_branch: String,
4292    git_commit: String,
4293    has_html: bool,
4294    has_json: bool,
4295    has_pdf: bool,
4296    submodule_links: Vec<SubmoduleLinkRow>,
4297    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
4298    submodule_names_csv: String,
4299}
4300
4301/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
4302fn nth_weekday_of_month(
4303    year: i32,
4304    month: u32,
4305    weekday: chrono::Weekday,
4306    n: u32,
4307) -> chrono::NaiveDate {
4308    use chrono::Datelike;
4309    let mut count = 0u32;
4310    let mut day = 1u32;
4311    loop {
4312        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
4313        if d.weekday() == weekday {
4314            count += 1;
4315            if count == n {
4316                return d;
4317            }
4318        }
4319        day += 1;
4320    }
4321}
4322
4323/// Returns true if `dt` falls within US Pacific Daylight Time.
4324/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
4325/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
4326fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
4327    use chrono::{Datelike, TimeZone};
4328    let year = dt.year();
4329    let dst_start = chrono::Utc.from_utc_datetime(
4330        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
4331            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
4332    );
4333    let dst_end = chrono::Utc.from_utc_datetime(
4334        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
4335            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
4336    );
4337    dt >= dst_start && dt < dst_end
4338}
4339
4340fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
4341    if is_pacific_dst(dt) {
4342        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
4343            .format("%Y-%m-%d %H:%M PDT")
4344            .to_string()
4345    } else {
4346        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
4347            .format("%Y-%m-%d %H:%M PST")
4348            .to_string()
4349    }
4350}
4351
4352fn fmt_git_date(iso: &str) -> Option<String> {
4353    chrono::DateTime::parse_from_rfc3339(iso)
4354        .ok()
4355        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
4356}
4357
4358fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
4359    reg.entries
4360        .iter()
4361        .map(|e| {
4362            let submodule_links = {
4363                let mut links: Vec<SubmoduleLinkRow> = vec![];
4364                let sub_dir = e
4365                    .html_path
4366                    .as_ref()
4367                    .and_then(|p| p.parent())
4368                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
4369                if let Some(dir) = sub_dir {
4370                    if let Ok(rd) = std::fs::read_dir(dir) {
4371                        for entry_res in rd.flatten() {
4372                            let fname = entry_res.file_name();
4373                            let fname_str = fname.to_string_lossy();
4374                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
4375                                let stem = &fname_str[..fname_str.len() - 5];
4376                                let display = stem[4..].replace('-', " ");
4377                                links.push(SubmoduleLinkRow {
4378                                    name: display,
4379                                    url: format!("/runs/{stem}/{}", e.run_id),
4380                                });
4381                            }
4382                        }
4383                    }
4384                }
4385                links.sort_by(|a, b| a.name.cmp(&b.name));
4386                links
4387            };
4388            let submodule_names_csv = submodule_links
4389                .iter()
4390                .map(|l| l.name.as_str())
4391                .collect::<Vec<_>>()
4392                .join(",");
4393            HistoryEntryRow {
4394                run_id: e.run_id.clone(),
4395                run_id_short: e
4396                    .run_id
4397                    .split('-')
4398                    .next_back()
4399                    .unwrap_or(&e.run_id)
4400                    .chars()
4401                    .take(7)
4402                    .collect(),
4403                timestamp: fmt_la_time(e.timestamp_utc),
4404                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
4405                project_label: e.project_label.clone(),
4406                project_path: e
4407                    .input_roots
4408                    .first()
4409                    .map(|s| sanitize_path_str(s))
4410                    .unwrap_or_default(),
4411                files_analyzed: e.summary.files_analyzed,
4412                files_skipped: e.summary.files_skipped,
4413                code_lines: e.summary.code_lines,
4414                comment_lines: e.summary.comment_lines,
4415                blank_lines: e.summary.blank_lines,
4416                git_branch: e.git_branch.clone().unwrap_or_default(),
4417                git_commit: e.git_commit.clone().unwrap_or_default(),
4418                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
4419                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
4420                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
4421                submodule_links,
4422                submodule_names_csv,
4423            }
4424        })
4425        .collect()
4426}
4427
4428#[derive(Deserialize, Default)]
4429struct HistoryQuery {
4430    linked: Option<String>,
4431    error: Option<String>,
4432}
4433
4434async fn history_handler(
4435    State(state): State<AppState>,
4436    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4437    Query(query): Query<HistoryQuery>,
4438) -> impl IntoResponse {
4439    // Auto-scan all watched directories before rendering so the list stays fresh.
4440    auto_scan_watched_dirs(&state).await;
4441    let watched_dirs: Vec<String> = {
4442        let wd = state.watched_dirs.lock().await;
4443        wd.dirs.iter().map(|p| p.display().to_string()).collect()
4444    };
4445    let mut entries = {
4446        let reg = state.registry.lock().await;
4447        make_history_rows(&reg)
4448    };
4449    entries.retain(|e| e.has_html);
4450    let total_scans = entries.len();
4451    let linked_count = query
4452        .linked
4453        .as_deref()
4454        .and_then(|s| s.parse::<usize>().ok())
4455        .unwrap_or(0);
4456    let browse_error = query.error.filter(|s| !s.is_empty());
4457    let template = HistoryTemplate {
4458        version: env!("CARGO_PKG_VERSION"),
4459        entries,
4460        total_scans,
4461        linked_count,
4462        browse_error,
4463        watched_dirs,
4464        csp_nonce,
4465    };
4466    Html(
4467        template
4468            .render()
4469            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4470    )
4471    .into_response()
4472}
4473
4474async fn compare_select_handler(
4475    State(state): State<AppState>,
4476    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4477) -> impl IntoResponse {
4478    auto_scan_watched_dirs(&state).await;
4479    let watched_dirs: Vec<String> = {
4480        let wd = state.watched_dirs.lock().await;
4481        wd.dirs.iter().map(|p| p.display().to_string()).collect()
4482    };
4483    let mut entries = {
4484        let reg = state.registry.lock().await;
4485        make_history_rows(&reg)
4486    };
4487    entries.retain(|e| e.has_json);
4488    let total_scans = entries.len();
4489    let template = CompareSelectTemplate {
4490        version: env!("CARGO_PKG_VERSION"),
4491        entries,
4492        total_scans,
4493        watched_dirs,
4494        csp_nonce,
4495    };
4496    Html(
4497        template
4498            .render()
4499            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4500    )
4501    .into_response()
4502}
4503
4504// ── Compare ───────────────────────────────────────────────────────────────────
4505
4506#[derive(Deserialize, Default)]
4507struct CompareQuery {
4508    a: Option<String>,
4509    b: Option<String>,
4510    /// Optional submodule name to scope the comparison to one submodule.
4511    sub: Option<String>,
4512    /// "super" to exclude all submodule files and show only the super-repo.
4513    scope: Option<String>,
4514}
4515
4516struct CompareFileDeltaRow {
4517    relative_path: String,
4518    language: String,
4519    status: String,
4520    baseline_code: i64,
4521    current_code: i64,
4522    code_delta_str: String,
4523    code_delta_class: String,
4524    comment_delta_str: String,
4525    comment_delta_class: String,
4526    total_delta_str: String,
4527    total_delta_class: String,
4528}
4529
4530/// Recompute `summary_totals` from the current `per_file_records` slice.
4531/// Used when `per_file_records` has been narrowed to a submodule subset.
4532fn recompute_summary_from_records(run: &mut AnalysisRun) {
4533    let files_analyzed = run
4534        .per_file_records
4535        .iter()
4536        .filter(|r| r.language.is_some())
4537        .count() as u64;
4538    let code_lines: u64 = run
4539        .per_file_records
4540        .iter()
4541        .map(|r| r.effective_counts.code_lines)
4542        .sum();
4543    let comment_lines: u64 = run
4544        .per_file_records
4545        .iter()
4546        .map(|r| r.effective_counts.comment_lines)
4547        .sum();
4548    let blank_lines: u64 = run
4549        .per_file_records
4550        .iter()
4551        .map(|r| r.effective_counts.blank_lines)
4552        .sum();
4553    run.summary_totals.files_analyzed = files_analyzed;
4554    run.summary_totals.files_considered = files_analyzed;
4555    run.summary_totals.code_lines = code_lines;
4556    run.summary_totals.comment_lines = comment_lines;
4557    run.summary_totals.blank_lines = blank_lines;
4558    run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
4559}
4560
4561fn fmt_delta(n: i64) -> String {
4562    if n > 0 {
4563        format!("+{n}")
4564    } else {
4565        format!("{n}")
4566    }
4567}
4568
4569fn delta_class(n: i64) -> &'static str {
4570    use std::cmp::Ordering;
4571    match n.cmp(&0) {
4572        Ordering::Greater => "pos",
4573        Ordering::Less => "neg",
4574        Ordering::Equal => "zero",
4575    }
4576}
4577
4578// ratio/percentage display, precision loss acceptable
4579#[allow(clippy::cast_precision_loss)]
4580fn fmt_pct(delta: i64, baseline: u64) -> String {
4581    if baseline == 0 {
4582        return "—".to_string();
4583    }
4584    #[allow(clippy::cast_precision_loss)]
4585    let pct = (delta as f64 / baseline as f64) * 100.0;
4586    if pct > 0.049 {
4587        format!("+{pct:.1}%")
4588    } else if pct < -0.049 {
4589        format!("{pct:.1}%")
4590    } else {
4591        "±0%".to_string()
4592    }
4593}
4594
4595/// Returns (`display_string`, `css_class`) for a numeric change column cell.
4596fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
4597    prev.map_or_else(
4598        || ("—".to_string(), "na"),
4599        |p| {
4600            #[allow(clippy::cast_possible_wrap)]
4601            let d = curr as i64 - p as i64;
4602            (fmt_delta(d), delta_class(d))
4603        },
4604    )
4605}
4606
4607#[allow(clippy::too_many_lines)]
4608async fn compare_handler(
4609    // NOSONAR(rust:S3776)
4610    State(state): State<AppState>,
4611    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4612    Query(query): Query<CompareQuery>,
4613) -> impl IntoResponse {
4614    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
4615    // redirect to the history page where the user can select two runs.
4616    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
4617        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
4618        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
4619    };
4620
4621    let (maybe_a, maybe_b) = {
4622        let reg = state.registry.lock().await;
4623        (
4624            reg.find_by_run_id(&run_id_a).cloned(),
4625            reg.find_by_run_id(&run_id_b).cloned(),
4626        )
4627    };
4628
4629    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
4630        let html = ErrorTemplate {
4631            message: "One or both run IDs were not found in scan history. \
4632                      The runs may have been deleted or the registry may have been reset."
4633                .to_string(),
4634            last_report_url: Some("/compare-scans".to_string()),
4635            last_report_label: Some("Compare Scans".to_string()),
4636            csp_nonce: csp_nonce.clone(),
4637        }
4638        .render()
4639        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
4640        return Html(html).into_response();
4641    };
4642
4643    // Ensure older scan is always the baseline.
4644    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
4645        (entry_a, entry_b)
4646    } else {
4647        (entry_b, entry_a)
4648    };
4649
4650    // If query params were in the wrong order, redirect to canonical URL so the
4651    // browser always shows the same URL for the same two scans regardless of how
4652    // the user arrived here (Full diff button vs. Compare Scans selection).
4653    if baseline_entry.run_id != run_id_a {
4654        let canonical = format!(
4655            "/compare?a={}&b={}",
4656            baseline_entry.run_id, current_entry.run_id
4657        );
4658        return axum::response::Redirect::to(&canonical).into_response();
4659    }
4660
4661    let (Some(base_json), Some(curr_json)) = (
4662        baseline_entry.json_path.as_ref(),
4663        current_entry.json_path.as_ref(),
4664    ) else {
4665        let html = ErrorTemplate {
4666            message: "Full comparison requires JSON scan data, which was not saved for one or \
4667                      both of these runs. JSON is now always saved for new scans — re-run the \
4668                      affected projects to enable comparisons."
4669                .to_string(),
4670            last_report_url: Some("/compare-scans".to_string()),
4671            last_report_label: Some("Compare Scans".to_string()),
4672            csp_nonce: csp_nonce.clone(),
4673        }
4674        .render()
4675        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
4676        return Html(html).into_response();
4677    };
4678
4679    let compare_url = format!(
4680        "/compare?a={}&b={}",
4681        baseline_entry.run_id, current_entry.run_id
4682    );
4683
4684    let baseline_run = match read_json(base_json) {
4685        Ok(r) => r,
4686        Err(e) => {
4687            if state.server_mode {
4688                let html = ErrorTemplate {
4689                    message: "Could not load baseline scan data. The scan output folder may \
4690                              have been moved, renamed, or deleted. Re-running the analysis \
4691                              will create fresh comparison data."
4692                        .to_string(),
4693                    last_report_url: Some("/compare-scans".to_string()),
4694                    last_report_label: Some("Compare Scans".to_string()),
4695                    csp_nonce: csp_nonce.clone(),
4696                }
4697                .render()
4698                .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
4699                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4700            }
4701            let msg = format!(
4702                "Could not load baseline scan data.\n\nExpected path: {}\n\nError: {e}",
4703                base_json.display()
4704            );
4705            let folder_hint = base_json
4706                .parent()
4707                .map(|p| p.display().to_string())
4708                .unwrap_or_default();
4709            return missing_scan_relocate_response(
4710                &msg,
4711                &baseline_entry.run_id,
4712                &folder_hint,
4713                &compare_url,
4714                false,
4715                &csp_nonce,
4716            );
4717        }
4718    };
4719    let current_run = match read_json(curr_json) {
4720        Ok(r) => r,
4721        Err(e) => {
4722            if state.server_mode {
4723                let html = ErrorTemplate {
4724                    message: "Could not load current scan data. The scan output folder may \
4725                              have been moved, renamed, or deleted. Re-running the analysis \
4726                              will create fresh comparison data."
4727                        .to_string(),
4728                    last_report_url: Some("/compare-scans".to_string()),
4729                    last_report_label: Some("Compare Scans".to_string()),
4730                    csp_nonce: csp_nonce.clone(),
4731                }
4732                .render()
4733                .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
4734                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4735            }
4736            let msg = format!(
4737                "Could not load current scan data.\n\nExpected path: {}\n\nError: {e}",
4738                curr_json.display()
4739            );
4740            let folder_hint = curr_json
4741                .parent()
4742                .map(|p| p.display().to_string())
4743                .unwrap_or_default();
4744            return missing_scan_relocate_response(
4745                &msg,
4746                &current_entry.run_id,
4747                &folder_hint,
4748                &compare_url,
4749                false,
4750                &csp_nonce,
4751            );
4752        }
4753    };
4754
4755    let active_submodule = query.sub.clone();
4756    let super_scope_active = query.scope.as_deref() == Some("super");
4757
4758    // Build the union of submodule names present in either run so users can
4759    // scope to a submodule even when it only exists in one of the two scans.
4760    let submodule_options = {
4761        let mut names = std::collections::BTreeSet::new();
4762        for s in &baseline_run.submodule_summaries {
4763            names.insert(s.name.clone());
4764        }
4765        for s in &current_run.submodule_summaries {
4766            names.insert(s.name.clone());
4767        }
4768        names.into_iter().collect::<Vec<_>>()
4769    };
4770    let has_any_submodule_data = !submodule_options.is_empty();
4771
4772    // Narrow per_file_records when a scope is active, then recompute totals.
4773    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
4774        let mut b = baseline_run;
4775        let mut c = current_run;
4776        b.per_file_records
4777            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4778        c.per_file_records
4779            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4780        recompute_summary_from_records(&mut b);
4781        recompute_summary_from_records(&mut c);
4782        (b, c)
4783    } else if super_scope_active {
4784        let mut b = baseline_run;
4785        let mut c = current_run;
4786        b.per_file_records.retain(|f| f.submodule.is_none());
4787        c.per_file_records.retain(|f| f.submodule.is_none());
4788        recompute_summary_from_records(&mut b);
4789        recompute_summary_from_records(&mut c);
4790        (b, c)
4791    } else {
4792        (baseline_run, current_run)
4793    };
4794
4795    let comparison = compute_delta(&effective_baseline, &effective_current);
4796
4797    let file_rows: Vec<CompareFileDeltaRow> = comparison
4798        .file_deltas
4799        .iter()
4800        .map(|d| CompareFileDeltaRow {
4801            relative_path: d.relative_path.clone(),
4802            language: d.language.clone().unwrap_or_else(|| "—".into()),
4803            status: match d.status {
4804                FileChangeStatus::Added => "added".into(),
4805                FileChangeStatus::Removed => "removed".into(),
4806                FileChangeStatus::Modified => "modified".into(),
4807                FileChangeStatus::Unchanged => "unchanged".into(),
4808            },
4809            baseline_code: d.baseline_code,
4810            current_code: d.current_code,
4811            code_delta_str: fmt_delta(d.code_delta),
4812            code_delta_class: delta_class(d.code_delta).into(),
4813            comment_delta_str: fmt_delta(d.comment_delta),
4814            comment_delta_class: delta_class(d.comment_delta).into(),
4815            total_delta_str: fmt_delta(d.total_delta),
4816            total_delta_class: delta_class(d.total_delta).into(),
4817        })
4818        .collect();
4819
4820    let project_path = baseline_entry
4821        .input_roots
4822        .first()
4823        .map(|s| sanitize_path_str(s))
4824        .unwrap_or_default();
4825    let lines_added = sum_added_code_lines(&comparison);
4826    let lines_removed = sum_removed_code_lines(&comparison);
4827    // True when the selected scope had no files in the baseline — e.g. comparing a submodule
4828    // that only exists in the current scan or using Super-repo only on an older scan.
4829    let new_scope = comparison.summary.baseline_code == 0 && comparison.summary.current_code > 0;
4830    // ratio/percentage display, precision loss acceptable
4831    #[allow(clippy::cast_precision_loss)]
4832    let churn_pct = if comparison.summary.baseline_code > 0 {
4833        (lines_added + lines_removed) as f64 / comparison.summary.baseline_code as f64 * 100.0
4834    } else {
4835        0.0
4836    };
4837    #[allow(clippy::cast_precision_loss)]
4838    let scope_flag = new_scope
4839        || (comparison.summary.baseline_code > 0
4840            && lines_added as f64 / comparison.summary.baseline_code as f64 > 0.20);
4841    let s = &comparison.summary;
4842    let template = CompareTemplate {
4843        version: env!("CARGO_PKG_VERSION"),
4844        project_label: baseline_entry.project_label.clone(),
4845        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
4846        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
4847        baseline_run_id: baseline_entry.run_id.clone(),
4848        current_run_id: current_entry.run_id.clone(),
4849        baseline_run_id_short: baseline_entry
4850            .run_id
4851            .split('-')
4852            .next_back()
4853            .unwrap_or(&baseline_entry.run_id)
4854            .chars()
4855            .take(7)
4856            .collect(),
4857        current_run_id_short: current_entry
4858            .run_id
4859            .split('-')
4860            .next_back()
4861            .unwrap_or(&current_entry.run_id)
4862            .chars()
4863            .take(7)
4864            .collect(),
4865        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
4866        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
4867        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
4868        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
4869        project_path: project_path.clone(),
4870        baseline_code: s.baseline_code,
4871        current_code: s.current_code,
4872        code_lines_delta_str: fmt_delta(s.code_lines_delta),
4873        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
4874        baseline_files: s.baseline_files,
4875        current_files: s.current_files,
4876        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
4877        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
4878        baseline_comments: s.baseline_comments,
4879        current_comments: s.current_comments,
4880        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
4881        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
4882        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
4883        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
4884        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
4885        code_lines_added: lines_added,
4886        code_lines_removed: lines_removed,
4887        new_scope,
4888        churn_rate_str: if new_scope {
4889            "New".to_string()
4890        } else if s.baseline_code > 0 {
4891            format!("{churn_pct:.1}%")
4892        } else {
4893            "—".to_string()
4894        },
4895        churn_rate_class: if new_scope || churn_pct > 20.0 {
4896            "high".into()
4897        } else if churn_pct > 5.0 {
4898            "med".into()
4899        } else {
4900            "low".into()
4901        },
4902        scope_flag,
4903        files_added: comparison.files_added,
4904        files_removed: comparison.files_removed,
4905        files_modified: comparison.files_modified,
4906        files_unchanged: comparison.files_unchanged,
4907        file_rows,
4908        baseline_git_author: baseline_entry.git_author.clone(),
4909        current_git_author: current_entry.git_author.clone(),
4910        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
4911        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
4912        baseline_git_tags: baseline_entry.git_tags.clone(),
4913        current_git_tags: current_entry.git_tags.clone(),
4914        baseline_git_commit_date: baseline_entry
4915            .git_commit_date
4916            .as_deref()
4917            .and_then(fmt_git_date),
4918        current_git_commit_date: current_entry
4919            .git_commit_date
4920            .as_deref()
4921            .and_then(fmt_git_date),
4922        project_name: project_path
4923            .rsplit(['/', '\\'])
4924            .find(|s| !s.is_empty())
4925            .unwrap_or(&project_path)
4926            .to_string(),
4927        submodule_options,
4928        has_any_submodule_data,
4929        active_submodule,
4930        super_scope_active,
4931        csp_nonce,
4932    };
4933
4934    Html(
4935        template
4936            .render()
4937            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4938    )
4939    .into_response()
4940}
4941
4942// ── Badge endpoint ────────────────────────────────────────────────────────────
4943// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
4944// pages, Jira descriptions, etc.
4945//
4946// GET /badge/<metric>?label=<override>&color=<hex>
4947// Metrics: code-lines  files  comment-lines  blank-lines
4948
4949fn format_number(n: u64) -> String {
4950    let s = n.to_string();
4951    let mut out = String::with_capacity(s.len() + s.len() / 3);
4952    let len = s.len();
4953    for (i, c) in s.chars().enumerate() {
4954        if i > 0 && (len - i).is_multiple_of(3) {
4955            out.push(',');
4956        }
4957        out.push(c);
4958    }
4959    out
4960}
4961
4962const fn badge_char_width(c: char) -> f64 {
4963    match c {
4964        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
4965        'm' | 'w' => 9.0,
4966        ' ' => 4.0,
4967        _ => 6.5,
4968    }
4969}
4970
4971#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4972fn badge_text_px(text: &str) -> u32 {
4973    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
4974}
4975
4976fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
4977    let lw = badge_text_px(label) + 20;
4978    let rw = badge_text_px(value) + 20;
4979    let total = lw + rw;
4980    let lx = lw / 2;
4981    let rx = lw + rw / 2;
4982    let le = escape_html(label);
4983    let ve = escape_html(value);
4984    let ce = escape_html(color);
4985    format!(
4986        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
4987  <rect width="{total}" height="20" fill="#555"/>
4988  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
4989  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
4990    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
4991    <text x="{lx}" y="13">{le}</text>
4992    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
4993    <text x="{rx}" y="13">{ve}</text>
4994  </g>
4995</svg>"##
4996    )
4997}
4998
4999#[derive(Deserialize)]
5000struct BadgeQuery {
5001    label: Option<String>,
5002    color: Option<String>,
5003}
5004
5005async fn badge_handler(
5006    State(state): State<AppState>,
5007    AxumPath(metric): AxumPath<String>,
5008    Query(query): Query<BadgeQuery>,
5009) -> Response {
5010    let entry = {
5011        let reg = state.registry.lock().await;
5012        reg.entries.first().cloned()
5013    };
5014
5015    let Some(entry) = entry else {
5016        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
5017        return (
5018            [
5019                (header::CONTENT_TYPE, "image/svg+xml"),
5020                (header::CACHE_CONTROL, "no-cache, max-age=0"),
5021            ],
5022            svg,
5023        )
5024            .into_response();
5025    };
5026
5027    let (default_label, value, default_color) = match metric.as_str() {
5028        "code-lines" => (
5029            "code lines",
5030            format_number(entry.summary.code_lines),
5031            "#4a78ee",
5032        ),
5033        "files" => (
5034            "files analyzed",
5035            format_number(entry.summary.files_analyzed),
5036            "#4a9862",
5037        ),
5038        "comment-lines" => (
5039            "comment lines",
5040            format_number(entry.summary.comment_lines),
5041            "#b35428",
5042        ),
5043        "blank-lines" => (
5044            "blank lines",
5045            format_number(entry.summary.blank_lines),
5046            "#7a5db0",
5047        ),
5048        _ => return StatusCode::NOT_FOUND.into_response(),
5049    };
5050
5051    let label = query.label.as_deref().unwrap_or(default_label);
5052    let color = query.color.as_deref().unwrap_or(default_color);
5053    let svg = render_badge_svg(label, &value, color);
5054
5055    (
5056        [
5057            (header::CONTENT_TYPE, "image/svg+xml"),
5058            (header::CACHE_CONTROL, "no-cache, max-age=0"),
5059        ],
5060        svg,
5061    )
5062        .into_response()
5063}
5064
5065// ── Metrics API ───────────────────────────────────────────────────────────────
5066// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
5067// Confluence automation, Jira webhooks, etc.
5068//
5069// GET /api/metrics/latest
5070// GET /api/metrics/<run_id>
5071
5072#[derive(Serialize)]
5073struct ApiMetricsResponse {
5074    run_id: String,
5075    timestamp: String,
5076    project: String,
5077    summary: ApiSummaryPayload,
5078    languages: Vec<ApiLanguageRow>,
5079}
5080
5081#[derive(Serialize)]
5082struct ApiSummaryPayload {
5083    files_analyzed: u64,
5084    files_skipped: u64,
5085    code_lines: u64,
5086    comment_lines: u64,
5087    blank_lines: u64,
5088    total_physical_lines: u64,
5089    functions: u64,
5090    classes: u64,
5091    variables: u64,
5092    imports: u64,
5093}
5094
5095#[derive(Serialize)]
5096struct ApiLanguageRow {
5097    name: String,
5098    files: u64,
5099    code_lines: u64,
5100    comment_lines: u64,
5101    blank_lines: u64,
5102    functions: u64,
5103    classes: u64,
5104    variables: u64,
5105    imports: u64,
5106}
5107
5108async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
5109    let entry = {
5110        let reg = state.registry.lock().await;
5111        reg.entries.first().cloned()
5112    };
5113    entry.map_or_else(
5114        || {
5115            (
5116                StatusCode::NOT_FOUND,
5117                Json(serde_json::json!({"error": "no scans recorded yet"})),
5118            )
5119                .into_response()
5120        },
5121        |e| build_metrics_response(&e),
5122    )
5123}
5124
5125async fn api_metrics_run_handler(
5126    State(state): State<AppState>,
5127    AxumPath(run_id): AxumPath<String>,
5128) -> Response {
5129    let entry = {
5130        let reg = state.registry.lock().await;
5131        reg.find_by_run_id(&run_id).cloned()
5132    };
5133    entry.map_or_else(
5134        || {
5135            (
5136                StatusCode::NOT_FOUND,
5137                Json(serde_json::json!({"error": "run not found"})),
5138            )
5139                .into_response()
5140        },
5141        |e| build_metrics_response(&e),
5142    )
5143}
5144
5145fn build_metrics_response(entry: &RegistryEntry) -> Response {
5146    let languages: Vec<ApiLanguageRow> = entry
5147        .json_path
5148        .as_ref()
5149        .and_then(|p| read_json(p).ok())
5150        .map(|run| {
5151            run.totals_by_language
5152                .iter()
5153                .map(|l| ApiLanguageRow {
5154                    name: l.language.display_name().to_string(),
5155                    files: l.files,
5156                    code_lines: l.code_lines,
5157                    comment_lines: l.comment_lines,
5158                    blank_lines: l.blank_lines,
5159                    functions: l.functions,
5160                    classes: l.classes,
5161                    variables: l.variables,
5162                    imports: l.imports,
5163                })
5164                .collect()
5165        })
5166        .unwrap_or_default();
5167
5168    let s = &entry.summary;
5169    Json(ApiMetricsResponse {
5170        run_id: entry.run_id.clone(),
5171        timestamp: entry.timestamp_utc.to_rfc3339(),
5172        project: entry.project_label.clone(),
5173        summary: ApiSummaryPayload {
5174            files_analyzed: s.files_analyzed,
5175            files_skipped: s.files_skipped,
5176            code_lines: s.code_lines,
5177            comment_lines: s.comment_lines,
5178            blank_lines: s.blank_lines,
5179            total_physical_lines: s.total_physical_lines,
5180            functions: s.functions,
5181            classes: s.classes,
5182            variables: s.variables,
5183            imports: s.imports,
5184        },
5185        languages,
5186    })
5187    .into_response()
5188}
5189
5190// ── Project history API ───────────────────────────────────────────────────────
5191// Protected. Called by the wizard JS when the project path changes, so the UI
5192// can show a "scanned N times before" badge without a full page reload.
5193//
5194// GET /api/project-history?path=<project_root>
5195
5196#[derive(Deserialize)]
5197struct ProjectHistoryQuery {
5198    path: Option<String>,
5199}
5200
5201#[derive(Serialize)]
5202struct ProjectHistoryResponse {
5203    scan_count: usize,
5204    last_scan_id: Option<String>,
5205    last_scan_timestamp: Option<String>,
5206    last_scan_code_lines: Option<u64>,
5207    last_git_branch: Option<String>,
5208    last_git_commit: Option<String>,
5209}
5210
5211async fn project_history_handler(
5212    State(state): State<AppState>,
5213    Query(query): Query<ProjectHistoryQuery>,
5214) -> Response {
5215    let path = query.path.unwrap_or_default();
5216    let resolved = resolve_input_path(&path);
5217    let root_str = resolved.to_string_lossy().replace('\\', "/");
5218
5219    let entries: Vec<_> = {
5220        let reg = state.registry.lock().await;
5221        reg.entries
5222            .iter()
5223            .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
5224            .cloned()
5225            .collect()
5226    };
5227    let scan_count = entries.len();
5228    let last = entries.first();
5229    let last_scan_id = last.map(|e| e.run_id.clone());
5230    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
5231    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
5232    let last_git_branch = last.and_then(|e| e.git_branch.clone());
5233    let last_git_commit = last.and_then(|e| e.git_commit.clone());
5234
5235    Json(ProjectHistoryResponse {
5236        scan_count,
5237        last_scan_id,
5238        last_scan_timestamp,
5239        last_scan_code_lines,
5240        last_git_branch,
5241        last_git_commit,
5242    })
5243    .into_response()
5244}
5245
5246// ── Metrics history API ───────────────────────────────────────────────────────
5247// Protected. Returns a JSON array of lightweight scan snapshots for plotting
5248// trend charts.
5249//
5250// GET /api/metrics/history?root=<path>&limit=<n>
5251
5252#[derive(Deserialize)]
5253struct MetricsHistoryQuery {
5254    root: Option<String>,
5255    limit: Option<usize>,
5256    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
5257    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
5258    submodule: Option<String>,
5259}
5260
5261#[derive(Serialize)]
5262struct MetricsSubmoduleLink {
5263    name: String,
5264    url: String,
5265}
5266
5267#[derive(Serialize)]
5268struct MetricsHistoryEntry {
5269    run_id: String,
5270    run_id_short: String,
5271    timestamp: String,
5272    commit: Option<String>,
5273    branch: Option<String>,
5274    tags: Vec<String>,
5275    nearest_tag: Option<String>,
5276    code_lines: u64,
5277    comment_lines: u64,
5278    blank_lines: u64,
5279    physical_lines: u64,
5280    files_analyzed: u64,
5281    files_skipped: u64,
5282    test_count: u64,
5283    project_label: String,
5284    html_url: Option<String>,
5285    has_pdf: bool,
5286    submodule_links: Vec<MetricsSubmoduleLink>,
5287}
5288
5289#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
5290async fn api_metrics_history_handler(
5291    // NOSONAR(rust:S3776)
5292    State(state): State<AppState>,
5293    Query(query): Query<MetricsHistoryQuery>,
5294) -> Response {
5295    let limit = query.limit.unwrap_or(50).min(500);
5296    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
5297
5298    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
5299        let reg = state.registry.lock().await;
5300        reg.entries
5301            .iter()
5302            .filter(|e| {
5303                query.root.as_ref().is_none_or(|root| {
5304                    let resolved = resolve_input_path(root);
5305                    let root_str = resolved.to_string_lossy().replace('\\', "/");
5306                    e.input_roots.iter().any(|r| r == &root_str)
5307                })
5308            })
5309            .take(limit)
5310            .cloned()
5311            .collect()
5312    };
5313
5314    let entries: Vec<MetricsHistoryEntry> = candidate_entries
5315        .into_iter()
5316        .filter_map(|e| {
5317            let tags = e
5318                .git_tags
5319                .as_deref()
5320                .map(|s| {
5321                    s.split(',')
5322                        .map(|t| t.trim().to_string())
5323                        .filter(|t| !t.is_empty())
5324                        .collect()
5325                })
5326                .unwrap_or_default();
5327            let html_url = e
5328                .html_path
5329                .as_ref()
5330                .filter(|p| p.exists())
5331                .map(|_| format!("/runs/html/{}", e.run_id));
5332            let nearest_tag = e.git_nearest_tag.clone();
5333            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
5334            let run_id_short: String = e
5335                .run_id
5336                .split('-')
5337                .next_back()
5338                .unwrap_or(&e.run_id)
5339                .chars()
5340                .take(7)
5341                .collect();
5342            let submodule_links: Vec<MetricsSubmoduleLink> = {
5343                let mut links: Vec<MetricsSubmoduleLink> = vec![];
5344                let sub_dir = e
5345                    .html_path
5346                    .as_ref()
5347                    .and_then(|p| p.parent())
5348                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5349                if let Some(dir) = sub_dir {
5350                    if let Ok(rd) = std::fs::read_dir(dir) {
5351                        for entry_res in rd.flatten() {
5352                            let fname = entry_res.file_name();
5353                            let fname_str = fname.to_string_lossy();
5354                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5355                                let stem = &fname_str[..fname_str.len() - 5];
5356                                let display = stem[4..].replace('-', " ");
5357                                links.push(MetricsSubmoduleLink {
5358                                    name: display,
5359                                    url: format!("/runs/{stem}/{}", e.run_id),
5360                                });
5361                            }
5362                        }
5363                    }
5364                }
5365                links.sort_by(|a, b| a.name.cmp(&b.name));
5366                links
5367            };
5368            let base = MetricsHistoryEntry {
5369                run_id: e.run_id.clone(),
5370                run_id_short,
5371                timestamp: e.timestamp_utc.to_rfc3339(),
5372                commit: e.git_commit.clone(),
5373                branch: e.git_branch.clone(),
5374                tags,
5375                nearest_tag,
5376                code_lines: e.summary.code_lines,
5377                comment_lines: e.summary.comment_lines,
5378                blank_lines: e.summary.blank_lines,
5379                physical_lines: e.summary.total_physical_lines,
5380                files_analyzed: e.summary.files_analyzed,
5381                files_skipped: e.summary.files_skipped,
5382                test_count: e.summary.test_count,
5383                project_label: e.project_label.clone(),
5384                html_url,
5385                has_pdf,
5386                submodule_links,
5387            };
5388            if let Some(ref filter) = submodule_filter {
5389                // Read the full JSON artifact to get per-submodule metrics.
5390                let json_path = e.json_path.as_ref()?;
5391                let json_str = std::fs::read_to_string(json_path).ok()?;
5392                let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
5393                let sub = run.submodule_summaries.iter().find(|s| {
5394                    s.name.to_lowercase() == *filter || s.relative_path.to_lowercase() == *filter
5395                })?;
5396                // Point the report link to the submodule sub-report if it was generated.
5397                let safe = sanitize_project_label(&sub.name);
5398                let artifact_key = format!("sub_{safe}");
5399                let sub_html_url = if let Some(run_dir) = std::path::Path::new(json_path).parent() {
5400                    let sub_path = run_dir.join(format!("{artifact_key}.html"));
5401                    if sub_path.exists() {
5402                        Some(format!("/runs/{artifact_key}/{}", e.run_id))
5403                    } else {
5404                        base.html_url.clone()
5405                    }
5406                } else {
5407                    base.html_url.clone()
5408                };
5409                Some(MetricsHistoryEntry {
5410                    code_lines: sub.code_lines,
5411                    comment_lines: sub.comment_lines,
5412                    blank_lines: sub.blank_lines,
5413                    physical_lines: sub.total_physical_lines,
5414                    files_analyzed: sub.files_analyzed,
5415                    html_url: sub_html_url,
5416                    has_pdf: false,
5417                    submodule_links: vec![],
5418                    ..base
5419                })
5420            } else {
5421                Some(base)
5422            }
5423        })
5424        .collect();
5425
5426    Json(entries).into_response()
5427}
5428
5429// GET /api/metrics/submodules?root=<path>
5430// Returns the union of distinct submodule names found across all saved scan JSON artifacts
5431// for the given project root (or all roots if omitted).
5432#[derive(Deserialize)]
5433struct MetricsSubmodulesQuery {
5434    root: Option<String>,
5435}
5436
5437#[derive(Serialize)]
5438struct SubmoduleEntry {
5439    name: String,
5440    relative_path: String,
5441}
5442
5443async fn api_metrics_submodules_handler(
5444    State(state): State<AppState>,
5445    Query(query): Query<MetricsSubmodulesQuery>,
5446) -> Response {
5447    let json_paths: Vec<std::path::PathBuf> = {
5448        let reg = state.registry.lock().await;
5449        reg.entries
5450            .iter()
5451            .filter(|e| {
5452                query.root.as_ref().is_none_or(|root| {
5453                    let resolved = resolve_input_path(root);
5454                    let root_str = resolved.to_string_lossy().replace('\\', "/");
5455                    e.input_roots.iter().any(|r| r == &root_str)
5456                })
5457            })
5458            .filter_map(|e| e.json_path.clone())
5459            .collect()
5460    };
5461
5462    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5463    let mut result: Vec<SubmoduleEntry> = Vec::new();
5464
5465    for path in &json_paths {
5466        let Ok(json_str) = std::fs::read_to_string(path) else {
5467            continue;
5468        };
5469        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
5470            continue;
5471        };
5472        for sub in &run.submodule_summaries {
5473            if seen.insert(sub.name.clone()) {
5474                result.push(SubmoduleEntry {
5475                    name: sub.name.clone(),
5476                    relative_path: sub.relative_path.clone(),
5477                });
5478            }
5479        }
5480    }
5481
5482    result.sort_by(|a, b| a.name.cmp(&b.name));
5483    Json(result).into_response()
5484}
5485
5486// ── CI ingest endpoint ────────────────────────────────────────────────────────
5487// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
5488// server stores and displays results without cloning or scanning anything itself.
5489//
5490// POST /api/ingest?label=<optional_display_name>
5491// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
5492// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
5493
5494#[derive(Deserialize)]
5495struct IngestQuery {
5496    label: Option<String>,
5497}
5498
5499async fn api_ingest_handler(
5500    State(state): State<AppState>,
5501    Query(q): Query<IngestQuery>,
5502    Json(run): Json<sloc_core::AnalysisRun>,
5503) -> Response {
5504    let label = q.label.unwrap_or_else(|| {
5505        run.input_roots
5506            .first()
5507            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
5508    });
5509
5510    let label_for_task = label.clone();
5511    let result = tokio::task::spawn_blocking(move || {
5512        let html = render_html(&run)?;
5513        let run_id = run.tool.run_id.clone();
5514        let run_id_safe = run_id.len() <= 128
5515            && !run_id.is_empty()
5516            && run_id
5517                .chars()
5518                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
5519        if !run_id_safe {
5520            anyhow::bail!(
5521                "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
5522            );
5523        }
5524        let project_label = sanitize_project_label(&label_for_task);
5525        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
5526        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
5527            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
5528            _ => project_label,
5529        };
5530        let (artifacts, _pending_pdf) = persist_run_artifacts(
5531            &run,
5532            &html,
5533            &output_dir,
5534            true,
5535            true,
5536            false,
5537            &label_for_task,
5538            &file_stem,
5539            RunResultContext::default(),
5540        )?;
5541        Ok::<_, anyhow::Error>((run_id, artifacts, run))
5542    })
5543    .await;
5544
5545    match result {
5546        Ok(Ok((run_id, artifacts, run))) => {
5547            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
5548            (
5549                StatusCode::CREATED,
5550                Json(serde_json::json!({
5551                    "run_id": run_id,
5552                    "view_url": format!("/view-reports?run_id={run_id}"),
5553                })),
5554            )
5555                .into_response()
5556        }
5557        Ok(Err(e)) => (
5558            StatusCode::INTERNAL_SERVER_ERROR,
5559            Json(serde_json::json!({"error": format!("{e:#}")})),
5560        )
5561            .into_response(),
5562        Err(e) => (
5563            StatusCode::INTERNAL_SERVER_ERROR,
5564            Json(serde_json::json!({"error": format!("{e}")})),
5565        )
5566            .into_response(),
5567    }
5568}
5569
5570// ── Trend report page ─────────────────────────────────────────────────────────
5571// Protected. Interactive time-series chart page that loads scan history via
5572// /api/metrics/history and renders a vanilla-SVG line chart.
5573//
5574// GET /trend-reports
5575
5576#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
5577async fn trend_report_handler(
5578    // NOSONAR(rust:S3776)
5579    State(state): State<AppState>,
5580    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5581) -> Response {
5582    auto_scan_watched_dirs(&state).await;
5583
5584    let watched_dirs_list: Vec<String> = {
5585        let wd = state.watched_dirs.lock().await;
5586        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5587    };
5588
5589    // Collect distinct project roots for the root selector dropdown.
5590    let roots: Vec<String> = {
5591        let reg = state.registry.lock().await;
5592        let mut seen = std::collections::BTreeSet::new();
5593        reg.entries
5594            .iter()
5595            .flat_map(|e| e.input_roots.iter().cloned())
5596            .filter(|r| seen.insert(r.clone()))
5597            .collect()
5598    };
5599
5600    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
5601    let nonce = &csp_nonce;
5602    let version = env!("CARGO_PKG_VERSION");
5603
5604    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
5605    let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
5606        r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
5607            .to_string()
5608    } else {
5609        watched_dirs_list
5610            .iter()
5611            .fold(String::new(), |mut s, d| {
5612                use std::fmt::Write as _;
5613                let escaped = d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
5614                write!(
5615                    s,
5616                    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>"#
5617                ).expect("write to String is infallible");
5618                s
5619            })
5620    };
5621    let watched_dirs_html = format!(
5622        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>"#
5623    );
5624
5625    let html = format!(
5626        r##"<!doctype html>
5627<html lang="en">
5628<head>
5629  <meta charset="utf-8" />
5630  <meta name="viewport" content="width=device-width, initial-scale=1" />
5631  <title>OxideSLOC | Trend Reports</title>
5632  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5633  <style nonce="{nonce}">
5634    :root {{
5635      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
5636      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5637      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
5638      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5639      --info-bg:#eef3ff; --info-text:#4467d8;
5640    }}
5641    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
5642    *{{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);}}
5643    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
5644    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
5645    .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;}}
5646    @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));}}}}
5647    .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);}}
5648    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
5649    .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));}}
5650    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
5651    .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;}}
5652    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
5653    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
5654    @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; }} }}
5655    .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;}}
5656    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
5657    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
5658    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
5659    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
5660    .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;}}
5661    .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;}}
5662    .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;}}
5663    .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;}}
5664    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
5665    .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);}}
5666    .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;}}
5667    .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;}}
5668    .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;}}
5669    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
5670    .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;}}
5671    .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);}}
5672    .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;}}
5673    .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;}}
5674    .tz-select:focus{{border-color:var(--oxide);}}
5675    .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
5676    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
5677    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
5678    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
5679    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
5680    .trend-title-block{{flex:1;min-width:0;}}
5681    .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;}}
5682    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
5683    .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;}}
5684    .chart-select:focus{{border-color:var(--accent);}}
5685    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
5686    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
5687    .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;}}
5688    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
5689    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
5690    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
5691    .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);}}
5692    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
5693    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
5694    .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;}}
5695    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
5696    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
5697    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
5698    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
5699    .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;}}
5700    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
5701    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
5702    .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);}}
5703    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
5704    .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;}}
5705    .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;}}
5706    .data-table tr:last-child td{{border-bottom:none;}}
5707    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
5708    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
5709    .table-wrap{{width:100%;overflow-x:auto;}}
5710    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
5711    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
5712    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
5713    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
5714    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
5715    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
5716    .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;}}
5717    .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;}}
5718    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
5719    .pagination-info{{font-size:13px;color:var(--muted);}}
5720    .pagination-btns{{display:flex;gap:6px;}}
5721    .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;}}
5722    .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;}}
5723    #scan-history-table col:nth-child(1){{width:155px;}}
5724    #scan-history-table col:nth-child(2){{width:240px;}}
5725    #scan-history-table col:nth-child(3){{width:82px;}}
5726    #scan-history-table col:nth-child(4){{width:82px;}}
5727    #scan-history-table col:nth-child(5){{width:90px;}}
5728    #scan-history-table col:nth-child(6){{width:90px;}}
5729    #scan-history-table col:nth-child(7){{width:88px;}}
5730    #scan-history-table col:nth-child(8){{width:150px;}}
5731    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
5732    .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;}}
5733    .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;}}
5734    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
5735    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
5736    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
5737    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
5738    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
5739    .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;}}
5740    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
5741    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
5742    .watched-chip-rm:hover{{color:var(--oxide);}}
5743    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
5744    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
5745    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
5746    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
5747    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
5748    a.run-link:hover{{text-decoration:underline;}}
5749    .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);}}
5750    .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);}}
5751    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
5752    .metric-num{{font-weight:700;color:var(--text);}}
5753    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
5754    .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;}}
5755    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
5756    .btn.primary:hover{{opacity:.9;}}
5757    .rpt-btn{{min-width:58px;justify-content:center;}}
5758    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
5759    .report-cell{{overflow:visible!important;white-space:normal!important;}}
5760    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
5761    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
5762    .submod-details summary::-webkit-details-marker{{display:none;}}
5763    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
5764    .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;}}
5765    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
5766    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
5767    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
5768    .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;}}
5769    .export-btn:hover{{background:var(--line);}}
5770    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
5771    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
5772    .site-footer a{{color:var(--muted);}}
5773    .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;}}
5774    .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;}}
5775    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
5776  </style>
5777</head>
5778<body>
5779  <div class="background-watermarks" aria-hidden="true">
5780    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5781    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5782    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5783    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5784    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5785    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5786  </div>
5787  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5788  <div class="top-nav">
5789    <div class="top-nav-inner">
5790      <a class="brand" href="/">
5791        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5792        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
5793      </a>
5794      <div class="nav-right">
5795        <a class="nav-pill" href="/">Home</a>
5796        <div class="nav-dropdown">
5797          <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>
5798          <div class="nav-dropdown-menu">
5799            <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>
5800          </div>
5801        </div>
5802        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5803        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
5804        <div class="nav-dropdown">
5805          <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>
5806          <div class="nav-dropdown-menu">
5807            <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>
5808          </div>
5809        </div>
5810        <div class="server-status-wrap">
5811          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
5812          <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>
5813        </div>
5814        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5815          <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>
5816        </button>
5817        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5818          <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>
5819          <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>
5820        </button>
5821      </div>
5822    </div>
5823  </div>
5824
5825  <div class="page">
5826    {watched_dirs_html}
5827    <div class="summary-strip" id="trend-stats"></div>
5828    <div class="panel">
5829      <div class="trend-header">
5830        <div class="trend-title-block">
5831          <h1>Trend Reports</h1>
5832          <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>
5833          <span class="chart-hint-inline">
5834            <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>
5835            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
5836          </span>
5837        </div>
5838        <div class="chart-actions">
5839          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
5840            <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>
5841            Export Excel
5842          </button>
5843          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
5844            <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>
5845            Export PNG
5846          </button>
5847        </div>
5848      </div>
5849
5850      <div class="controls-centered">
5851        <label>Project Root:
5852          <select class="chart-select" id="root-sel">
5853            <option value="">All projects</option>
5854          </select>
5855        </label>
5856        <label>Y Metric:
5857          <select class="chart-select" id="y-sel">
5858            <option value="code_lines">Code Lines</option>
5859            <option value="comment_lines">Comment Lines</option>
5860            <option value="blank_lines">Blank Lines</option>
5861            <option value="physical_lines">Physical Lines</option>
5862            <option value="files_analyzed">Files Analyzed</option>
5863          </select>
5864        </label>
5865        <label>X Axis:
5866          <select class="chart-select" id="x-sel">
5867            <option value="time">By Time</option>
5868            <option value="commit">By Commit</option>
5869            <option value="release">By Release</option>
5870            <option value="tag">Tagged Commits</option>
5871          </select>
5872        </label>
5873        <label id="submodule-label" style="display:none;">Submodule:
5874          <select class="chart-select" id="sub-sel">
5875            <option value="">All (project total)</option>
5876          </select>
5877        </label>
5878        <label>Chart Size:
5879          <select class="chart-select" id="scale-sel">
5880            <option value="0.75">Compact</option>
5881            <option value="1.2" selected>Normal</option>
5882            <option value="1.38">Large</option>
5883          </select>
5884        </label>
5885      </div>
5886
5887      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
5888      <div id="data-table-wrap" style="overflow-x:auto;"></div>
5889    </div>
5890  </div>
5891
5892  <script nonce="{nonce}">
5893    (function() {{
5894      // Theme persistence
5895      var b = document.body;
5896      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
5897      var tgl = document.getElementById('theme-toggle');
5898      if (tgl) tgl.addEventListener('click', function() {{
5899        var d = b.classList.toggle('dark-theme');
5900        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
5901      }});
5902
5903      // Watermark randomizer
5904      (function() {{
5905        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
5906        if (!wms.length) return;
5907        var placed = [];
5908        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;}}
5909        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];}}
5910        var half=Math.floor(wms.length/2);
5911        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;}});
5912      }})();
5913
5914      // Code particles
5915      (function() {{
5916        var container = document.getElementById('code-particles');
5917        if (!container) return;
5918        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'];
5919        for (var i = 0; i < 38; i++) {{
5920          (function(idx) {{
5921            var el = document.createElement('span');
5922            el.className = 'code-particle';
5923            el.textContent = snippets[idx % snippets.length];
5924            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
5925            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
5926            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
5927            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';
5928            container.appendChild(el);
5929          }})(i);
5930        }}
5931      }})();
5932
5933      // Watched folder picker
5934      (function() {{
5935        var btn = document.getElementById('add-watched-btn');
5936        if (!btn) return;
5937        btn.addEventListener('click', function() {{
5938          fetch('/pick-directory?kind=reports')
5939            .then(function(r) {{ return r.json(); }})
5940            .then(function(data) {{
5941              if (!data.cancelled && data.selected_path) {{
5942                var form = document.createElement('form');
5943                form.method = 'POST';
5944                form.action = '/watched-dirs/add';
5945                var ri = document.createElement('input');
5946                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
5947                var fi = document.createElement('input');
5948                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
5949                form.appendChild(ri); form.appendChild(fi);
5950                document.body.appendChild(form);
5951                form.submit();
5952              }}
5953            }})
5954            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
5955        }});
5956      }})();
5957
5958      // Settings / color-scheme modal
5959      (function() {{
5960        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'}}];
5961        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);}});}}
5962        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
5963        var btn=document.getElementById('settings-btn');if(!btn)return;
5964        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
5965        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>';
5966        document.body.appendChild(m);
5967        var g=document.getElementById('scheme-grid');
5968        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);}});
5969        var cl=document.getElementById('settings-close');
5970        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);
5971        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');}});
5972        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
5973        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
5974      }})();
5975    }})();
5976
5977    var ROOTS = {roots_json};
5978    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
5979    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
5980    var allData = [];
5981
5982    // Populate root selector
5983    var rootSel = document.getElementById('root-sel');
5984    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
5985
5986    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();}}
5987    function fmtFull(n){{return Number(n).toLocaleString();}}
5988    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
5989
5990    // Tooltip
5991    var tt = document.createElement('div');
5992    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);';
5993    document.body.appendChild(tt);
5994    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
5995    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';}}
5996    function hideTT(){{tt.style.display='none';}}
5997
5998    function statExact(compact, full){{
5999      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
6000    }}
6001    function statVal(n){{
6002      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
6003    }}
6004
6005    function updateStats(data){{
6006      var statsEl=document.getElementById('trend-stats');
6007      if(!statsEl)return;
6008      if(!data||!data.length){{statsEl.innerHTML='';return;}}
6009      var yKey=document.getElementById('y-sel').value;
6010      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
6011      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6012      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
6013      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
6014      var absDelta=Math.abs(delta);
6015      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
6016      var deltaExact=statExact(deltaCompact,deltaFull);
6017      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
6018      statsEl.innerHTML=
6019        '<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>'+
6020        '<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>'+
6021        '<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>'+
6022        '<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>';
6023    }}
6024
6025    var subSel = document.getElementById('sub-sel');
6026    var subLabel = document.getElementById('submodule-label');
6027
6028    function populateSubmodules(root){{
6029      if(!subSel||!subLabel)return;
6030      while(subSel.options.length>1)subSel.remove(1);
6031      subSel.value='';
6032      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
6033      fetch(url)
6034        .then(function(r){{return r.json();}})
6035        .then(function(subs){{
6036          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
6037          subs.forEach(function(s){{
6038            var o=document.createElement('option');
6039            o.value=s.name;
6040            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
6041            subSel.appendChild(o);
6042          }});
6043          subLabel.style.display='';
6044        }})
6045        .catch(function(){{subLabel.style.display='none';}});
6046    }}
6047
6048    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div>';
6049
6050    function loadAndRender(){{
6051      var root = rootSel.value;
6052      var sub = subSel ? subSel.value : '';
6053      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
6054      document.getElementById('data-table-wrap').innerHTML='';
6055      var url = '/api/metrics/history?limit=100'
6056        + (root ? '&root='+encodeURIComponent(root) : '')
6057        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
6058      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
6059        allData = data;
6060        render(data);
6061        updateStats(data);
6062      }}).catch(function(){{
6063        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>';
6064      }});
6065    }}
6066
6067    function render(data){{
6068      var yKey = document.getElementById('y-sel').value;
6069      var xMode = document.getElementById('x-sel').value;
6070
6071      // Filter for tag/release mode
6072      var pts = data;
6073      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
6074
6075      // Sort oldest-first for the line chart
6076      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6077
6078      var wrap = document.getElementById('chart-wrap');
6079      if(!pts.length){{
6080        var emptyMsg = (xMode === 'tag')
6081          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
6082          : 'No scan data found for the selected filters.';
6083        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
6084        renderTable([]);
6085        return;
6086      }}
6087
6088      var scaleEl=document.getElementById('scale-sel');
6089      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
6090      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;
6091      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
6092
6093      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
6094
6095      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">';
6096      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>';
6097
6098      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
6099
6100      // Grid + Y axis ticks
6101      for(var ti=0;ti<=5;ti++){{
6102        var gy=PT+CH-Math.round(ti/5*CH);
6103        var gv=Math.round(ti/5*maxY);
6104        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
6105        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
6106      }}
6107
6108      // X axis labels (every N-th point to avoid crowding)
6109      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
6110      pts.forEach(function(d,i){{
6111        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6112        if(i%labelEvery===0||i===pts.length-1){{
6113          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)));
6114          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>';
6115        }}
6116      }});
6117
6118      // Axis label
6119      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
6120      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>';
6121      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>';
6122
6123      // Area fill + line path
6124      var pathD='';
6125      pts.forEach(function(d,i){{
6126        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6127        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6128        pathD+=(i===0?'M':'L')+x+','+y;
6129      }});
6130      if(pts.length>1){{
6131        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
6132        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
6133      }}
6134      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
6135
6136      // Data points (clickable) + permanent value labels
6137      var showLabels = pts.length <= 40;
6138      var labelEveryN = pts.length > 20 ? 2 : 1;
6139      pts.forEach(function(d,i){{
6140        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6141        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6142        var hasTags=d.tags&&d.tags.length>0;
6143        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
6144        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
6145        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+'"/>';
6146        if(showLabels && i%labelEveryN===0){{
6147          var lx=x, ly=y-r-5;
6148          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>';
6149        }}
6150      }});
6151
6152      svg+='</svg>';
6153      wrap.innerHTML=svg;
6154
6155      // Attach point tooltips
6156      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
6157        c.addEventListener('mouseover',function(e){{
6158          var d=pts[parseInt(this.dataset.idx)];
6159          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(''):'';
6160          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>':'';
6161          showTT(e,
6162            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
6163            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
6164            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
6165            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
6166          );
6167          this.setAttribute('r','8');
6168        }});
6169        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
6170        c.addEventListener('mousemove',moveTT);
6171        c.addEventListener('click',function(){{
6172          var d=pts[parseInt(this.dataset.idx)];
6173          if(d.html_url) window.open(d.html_url,'_blank');
6174        }});
6175      }});
6176
6177      renderTable(pts, yKey);
6178    }}
6179
6180    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
6181    var shProjFilter='', shBranchFilter='';
6182
6183    function fmtPST(isoStr){{
6184      if(!isoStr)return'';
6185      var d=new Date(isoStr);
6186      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
6187      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);}}
6188      function p(n){{return n<10?'0'+n:String(n);}}
6189      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++;}}}}
6190      var yr=d.getUTCFullYear();
6191      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
6192      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
6193      var isDST=d>=dstStart&&d<dstEnd;
6194      var off=isDST?-7*3600*1000:-8*3600*1000;
6195      var lbl=isDST?'PDT':'PST';
6196      var loc=new Date(d.getTime()+off);
6197      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
6198    }}
6199
6200    function getShRows(){{
6201      var proj=shProjFilter.toLowerCase().trim();
6202      var branch=shBranchFilter;
6203      return shData.filter(function(d){{
6204        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
6205        if(branch&&(d.branch||'')!==branch)return false;
6206        return true;
6207      }});
6208    }}
6209
6210    function renderShPage(){{
6211      var filtered=getShRows();
6212      if(shSortCol){{
6213        filtered.sort(function(a,b){{
6214          var va,vb;
6215          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
6216          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
6217          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
6218          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
6219          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
6220          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
6221        }});
6222      }}
6223      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
6224      shPage=Math.min(shPage,totalPages);
6225      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
6226      var visible=filtered.slice(start,end);
6227      var tbody=document.getElementById('sh-tbody');
6228      if(!tbody)return;
6229      tbody.innerHTML=visible.map(function(d){{
6230        var tsHtml=esc(fmtPST(d.timestamp));
6231        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>';
6232        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>';
6233        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
6234        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
6235        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
6236        var reportCell='';
6237        if(d.html_url){{
6238          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
6239          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>';}}
6240          reportCell+='</div>';
6241        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
6242        if(d.submodule_links&&d.submodule_links.length){{
6243          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
6244          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
6245          reportCell+='</div></details>';
6246        }}
6247        return '<tr>'
6248          +'<td>'+tsHtml+'</td>'
6249          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
6250          +'<td>'+runIdHtml+'</td>'
6251          +'<td>'+commitHtml+'</td>'
6252          +'<td>'+branchHtml+'</td>'
6253          +'<td>'+tags+'</td>'
6254          +'<td class="num">'+metricHtml+'</td>'
6255          +'<td class="report-cell">'+reportCell+'</td>'
6256          +'</tr>';
6257      }}).join('');
6258      var pgRange=document.getElementById('sh-pg-range');
6259      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'–'+end+' of '+total:'No results';
6260      var pgInfo=document.getElementById('sh-pg-info');
6261      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
6262      var pgBtns=document.getElementById('sh-pg-btns');
6263      if(pgBtns){{
6264        pgBtns.innerHTML='';
6265        function mkPgBtn(lbl,pg,active,disabled){{
6266          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
6267          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
6268          return b;
6269        }}
6270        pgBtns.appendChild(mkPgBtn('‹',shPage-1,false,shPage===1));
6271        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
6272        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
6273        pgBtns.appendChild(mkPgBtn('›',shPage+1,false,shPage===totalPages));
6274      }}
6275    }}
6276
6277    function wireTableBehavior(){{
6278      var pf=document.getElementById('sh-proj-filter');
6279      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
6280      var bf=document.getElementById('sh-branch-filter');
6281      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
6282      var rb=document.getElementById('sh-reset-btn');
6283      if(rb)rb.addEventListener('click',function(){{
6284        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
6285        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
6286        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
6287        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');}});
6288        renderShPage();
6289      }});
6290      var pps=document.getElementById('sh-per-page');
6291      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
6292      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
6293      ths.forEach(function(th){{
6294        th.addEventListener('click',function(e){{
6295          if(e.target.classList.contains('col-resize-handle'))return;
6296          var col=th.dataset.col;
6297          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
6298          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
6299          th.classList.add('sort-'+shSortOrder);
6300          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
6301          shPage=1;renderShPage();
6302        }});
6303      }});
6304      var table=document.getElementById('scan-history-table');
6305      if(!table)return;
6306      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
6307      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
6308      allThs.forEach(function(th,i){{
6309        var handle=th.querySelector('.col-resize-handle');
6310        if(!handle||!cols[i])return;
6311        var startX,startW;
6312        handle.addEventListener('mousedown',function(e){{
6313          e.stopPropagation();e.preventDefault();
6314          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
6315          handle.classList.add('dragging');
6316          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
6317          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
6318          document.addEventListener('mousemove',onMove);
6319          document.addEventListener('mouseup',onUp);
6320        }});
6321      }});
6322    }}
6323
6324    function renderTable(pts, yKey){{
6325      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
6326      var wrap=document.getElementById('data-table-wrap');
6327      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
6328      var yLabel=Y_LABELS[yKey]||yKey||'';
6329      shData=pts.slice().reverse();
6330      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
6331      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
6332      var branches={{}};
6333      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
6334      var branchOpts='<option value="">All branches</option>';
6335      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
6336      wrap.innerHTML=
6337        '<div class="chart-section-header">SCAN HISTORY</div>'+
6338        '<div class="filter-row">'+
6339          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project…">'+
6340          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
6341          '<button type="button" class="btn" id="sh-reset-btn">↻ Reset view</button>'+
6342        '</div>'+
6343        '<div class="table-wrap">'+
6344        '<table id="scan-history-table" class="data-table">'+
6345        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
6346        '<thead><tr id="sh-thead">'+
6347        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6348        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6349        '<th>Run ID<div class="col-resize-handle"></div></th>'+
6350        '<th>Commit<div class="col-resize-handle"></div></th>'+
6351        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6352        '<th>Tags<div class="col-resize-handle"></div></th>'+
6353        '<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>'+
6354        '<th>Report<div class="col-resize-handle"></div></th>'+
6355        '</tr></thead>'+
6356        '<tbody id="sh-tbody"></tbody>'+
6357        '</table>'+
6358        '</div>'+
6359        '<div class="pagination">'+
6360          '<span class="pagination-info" id="sh-pg-info"></span>'+
6361          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
6362          '<div style="display:flex;align-items:center;gap:8px;">'+
6363            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
6364            '<select class="filter-select" id="sh-per-page">'+
6365              '<option value="10">10 per page</option>'+
6366              '<option value="25" selected>25 per page</option>'+
6367              '<option value="50">50 per page</option>'+
6368              '<option value="100">100 per page</option>'+
6369            '</select>'+
6370            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
6371          '</div>'+
6372        '</div>';
6373      wireTableBehavior();
6374      renderShPage();
6375    }}
6376
6377    function exportXLSX(){{
6378      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
6379      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
6380      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
6381      var s1R=sorted.map(function(d){{
6382        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||''];
6383      }});
6384      var pm={{}};
6385      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
6386      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'];
6387      var s2R=Object.keys(pm).map(function(p){{
6388        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6389        var lat=sc[sc.length-1],fst=sc[0];
6390        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
6391        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);
6392        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];
6393      }});
6394      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
6395      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
6396      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
6397      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
6398    }}
6399
6400    function buildXLSX(sheets,chartRows,chartRows2){{
6401      function s2b(s){{return new TextEncoder().encode(s);}}
6402      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
6403      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;}}
6404      function crc32(d){{
6405        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;}}}}
6406        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
6407      }}
6408      function buildSheet(hdr,rows,drawRid,withCtrl){{
6409        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
6410        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
6411        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
6412        x+='<row r="1">';
6413        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
6414        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>';}}
6415        x+='</row>';
6416        rows.forEach(function(row,ri){{
6417          var rn=ri+2;
6418          x+='<row r="'+rn+'">';
6419          row.forEach(function(cell,ci){{
6420            var addr=col2l(ci+1)+rn;
6421            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
6422            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
6423          }});
6424          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>';}}
6425          x+='</row>';
6426        }});
6427        x+='</sheetData>';
6428        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>';}}
6429        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
6430        return x+'</worksheet>';
6431      }}
6432      function buildChartXML(rows){{
6433        var sn="'Scan History'";
6434        var nr=rows.length,er=nr+1;
6435        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'}}];
6436        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6437        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">';
6438        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6439        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6440        sd.forEach(function(s,i){{
6441          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6442          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>';
6443          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6444          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>';
6445          var dlp=(i===2)?'b':'t';
6446          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>';
6447          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6448          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6449          x+='</c:strCache></c:strRef></c:cat>';
6450          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+'"/>';
6451          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6452          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6453        }});
6454        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
6455        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>';
6456        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>';
6457        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6458        return x;
6459      }}
6460      function buildChartXML2(rows){{
6461        var sn="'By Project'";
6462        var nr=rows.length,er=nr+1;
6463        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'}}];
6464        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6465        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">';
6466        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6467        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6468        sd.forEach(function(s,i){{
6469          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6470          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>';
6471          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6472          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>';
6473          var dlp=(i===2)?'b':'t';
6474          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>';
6475          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6476          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6477          x+='</c:strCache></c:strRef></c:cat>';
6478          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+'"/>';
6479          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6480          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6481        }});
6482        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
6483        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>';
6484        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>';
6485        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6486        return x;
6487      }}
6488      function buildChartXML3(rows){{
6489        var sn="'Scan History'";
6490        var nr=rows.length,er=nr+1;
6491        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6492        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">';
6493        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
6494        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6495        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
6496        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>';
6497        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
6498        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>';
6499        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>';
6500        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6501        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6502        x+='</c:strCache></c:strRef></c:cat>';
6503        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+'"/>';
6504        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
6505        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6506        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
6507        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>';
6508        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>';
6509        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>';
6510        return x;
6511      }}
6512      var hasChart=!!(chartRows&&chartRows.length);
6513      var nr=hasChart?chartRows.length:0;
6514      var hasChart2=!!(chartRows2&&chartRows2.length);
6515      var nr2=hasChart2?chartRows2.length:0;
6516      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>';
6517      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"/>';
6518      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
6519      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"/>';}}
6520      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"/>';}}
6521      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
6522      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>';
6523      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
6524      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"/>';}});
6525      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
6526      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>';
6527      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
6528      wbx+='</sheets></workbook>';
6529      var files=[
6530        {{name:'[Content_Types].xml',data:s2b(ct)}},
6531        {{name:'_rels/.rels',data:s2b(dotrels)}},
6532        {{name:'xl/workbook.xml',data:s2b(wbx)}},
6533        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
6534        {{name:'xl/styles.xml',data:s2b(styl)}}
6535      ];
6536      // Chart embedded directly in Scan History (sheet1); By Project is plain
6537      sheets.forEach(function(s,i){{
6538        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)))}});
6539      }});
6540      if(hasChart){{
6541        var fromRow=nr+4,toRow=nr+24;
6542        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>')}});
6543        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6544        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">';
6545        drx+='<xdr:twoCellAnchor editAs="twoCell">';
6546        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>';
6547        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>';
6548        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6549        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6550        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6551        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6552        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
6553        var focRow=toRow+2,focRowEnd=toRow+22;
6554        drx+='<xdr:twoCellAnchor editAs="twoCell">';
6555        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>';
6556        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>';
6557        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6558        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6559        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6560        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
6561        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
6562        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
6563        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>')}});
6564        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
6565        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
6566      }}
6567      if(hasChart2){{
6568        var fromRow2=nr2+4,toRow2=nr2+24;
6569        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>')}});
6570        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6571        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">';
6572        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
6573        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>';
6574        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>';
6575        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6576        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6577        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6578        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6579        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
6580        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
6581        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>')}});
6582        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
6583      }}
6584      var parts=[],offsets=[],total=0;
6585      files.forEach(function(f){{
6586        offsets.push(total);
6587        var nb=s2b(f.name),crc=crc32(f.data);
6588        var h=new DataView(new ArrayBuffer(30+nb.length));
6589        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
6590        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
6591        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
6592        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
6593        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
6594        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
6595        total+=30+nb.length+f.data.length;
6596      }});
6597      var cdStart=total;
6598      files.forEach(function(f,fi){{
6599        var nb=s2b(f.name),crc=crc32(f.data);
6600        var cd=new DataView(new ArrayBuffer(46+nb.length));
6601        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
6602        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
6603        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
6604        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
6605        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
6606        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
6607        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
6608      }});
6609      var cdSz=total-cdStart;
6610      var eocd=new DataView(new ArrayBuffer(22));
6611      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
6612      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
6613      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
6614      parts.push(new Uint8Array(eocd.buffer));
6615      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
6616      var out=new Uint8Array(sz);var off=0;
6617      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
6618      return out.buffer;
6619    }}
6620
6621    function exportPNG(){{
6622      var svgEl=document.querySelector('#chart-wrap svg');
6623      if(!svgEl){{alert('No chart to export yet.');return;}}
6624      var svgStr=new XMLSerializer().serializeToString(svgEl);
6625      var vb=svgEl.viewBox.baseVal,scale=2;
6626      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
6627      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
6628      var url=URL.createObjectURL(blob);
6629      var img=new Image();
6630      img.onload=function(){{
6631        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
6632        var ctx=canvas.getContext('2d');
6633        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
6634        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
6635        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
6636        URL.revokeObjectURL(url);
6637        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
6638      }};
6639      img.src=url;
6640    }}
6641
6642    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
6643      var el=document.getElementById(id);
6644      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
6645    }});
6646    rootSel.addEventListener('change',function(){{
6647      populateSubmodules(rootSel.value);
6648      loadAndRender();
6649    }});
6650    if(subSel)subSel.addEventListener('change',loadAndRender);
6651
6652    var xlsxBtn=document.getElementById('export-xlsx-btn');
6653    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
6654    var pngBtn=document.getElementById('export-png-btn');
6655    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
6656
6657    populateSubmodules(rootSel.value);
6658    loadAndRender();
6659
6660    (function randomizeWatermarks() {{
6661      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6662      if (!wms.length) return;
6663      var placed = [];
6664      function tooClose(top, left) {{
6665        for (var i = 0; i < placed.length; i++) {{
6666          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
6667          if (dt < 16 && dl < 12) return true;
6668        }}
6669        return false;
6670      }}
6671      function pick(leftBand) {{
6672        for (var attempt = 0; attempt < 50; attempt++) {{
6673          var top = Math.random() * 88 + 2;
6674          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6675          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
6676        }}
6677        var top = Math.random() * 88 + 2;
6678        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6679        placed.push([top, left]); return [top, left];
6680      }}
6681      var half = Math.floor(wms.length / 2);
6682      wms.forEach(function (img, i) {{
6683        var pos = pick(i < half);
6684        var size = Math.floor(Math.random() * 100 + 120);
6685        var rot = (Math.random() * 360).toFixed(1);
6686        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
6687        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;
6688      }});
6689    }})();
6690    (function spawnCodeParticles() {{
6691      var container = document.getElementById('code-particles');
6692      if (!container) return;
6693      var snippets = [
6694        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
6695        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
6696        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
6697        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
6698        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
6699      ];
6700      var count = 38;
6701      for (var i = 0; i < count; i++) {{
6702        (function(idx) {{
6703          var el = document.createElement('span');
6704          el.className = 'code-particle';
6705          el.textContent = snippets[idx % snippets.length];
6706          var left = Math.random() * 94 + 2;
6707          var top = Math.random() * 88 + 6;
6708          var dur = (Math.random() * 10 + 9).toFixed(1);
6709          var delay = (Math.random() * 18).toFixed(1);
6710          var rot = (Math.random() * 26 - 13).toFixed(1);
6711          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6712          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
6713          container.appendChild(el);
6714        }})(i);
6715      }}
6716    }})();
6717  </script>
6718  <footer class="site-footer">
6719    oxide-sloc v{version} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
6720    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6721    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6722    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6723    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
6724  </footer>
6725</body>
6726</html>"##,
6727    );
6728
6729    Html(html).into_response()
6730}
6731
6732#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
6733#[allow(clippy::too_many_lines)] // JSON data builder for test-metrics scope; splitting would scatter related fields
6734fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
6735    // NOSONAR(rust:S3776)
6736    use std::collections::HashMap;
6737    let mut langs: Vec<&sloc_core::LanguageSummary> = run
6738        .totals_by_language
6739        .iter()
6740        .filter(|l| l.test_count > 0)
6741        .collect();
6742    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6743    let lang_tests: Vec<serde_json::Value> = langs
6744        .iter()
6745        .map(|l| {
6746            let d = if l.code_lines > 0 {
6747                l.test_count as f64 / l.code_lines as f64 * 1000.0
6748            } else {
6749                0.0
6750            };
6751            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6752                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6753                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6754        })
6755        .collect();
6756    let has_file_cov = run.per_file_records.iter().any(|f| f.coverage.is_some());
6757    let cov_arr: Vec<serde_json::Value> = if has_file_cov {
6758        let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
6759        for rec in &run.per_file_records {
6760            if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
6761                let e = totals.entry(lang.display_name().to_string()).or_default();
6762                e.0 += u64::from(cov.lines_found);
6763                e.1 += u64::from(cov.lines_hit);
6764            }
6765        }
6766        let mut pairs: Vec<(String, f64)> = totals
6767            .into_iter()
6768            .filter(|(_, (found, _))| *found > 0)
6769            .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
6770            .collect();
6771        pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6772        pairs
6773            .iter()
6774            .map(
6775                |(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}),
6776            )
6777            .collect()
6778    } else {
6779        vec![]
6780    };
6781    let (mut high, mut mid, mut low) = (0u64, 0u64, 0u64);
6782    for rec in &run.per_file_records {
6783        if let Some(cov) = &rec.coverage {
6784            if cov.lines_found == 0 {
6785                continue;
6786            }
6787            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
6788            if pct >= 80.0 {
6789                high += 1;
6790            } else if pct >= 50.0 {
6791                mid += 1;
6792            } else {
6793                low += 1;
6794            }
6795        }
6796    }
6797    let t = &run.summary_totals;
6798    let total_tests = t.test_count;
6799    let density = if t.code_lines > 0 {
6800        total_tests as f64 / t.code_lines as f64 * 1000.0
6801    } else {
6802        0.0
6803    };
6804    let most_tested = langs.first().map_or_else(
6805        || "\u{2014}".to_string(),
6806        |l| l.language.display_name().to_string(),
6807    );
6808    let test_files: u64 = run
6809        .per_file_records
6810        .iter()
6811        .filter(|f| f.raw_line_categories.test_count > 0)
6812        .count() as u64;
6813    let cov_line = if t.coverage_lines_found > 0 {
6814        format!(
6815            "{:.1}",
6816            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
6817        )
6818    } else {
6819        "0".to_string()
6820    };
6821    let cov_fn = if t.coverage_functions_found > 0 {
6822        format!(
6823            "{:.1}",
6824            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
6825        )
6826    } else {
6827        "0".to_string()
6828    };
6829    let cov_branch = if t.coverage_branches_found > 0 {
6830        format!(
6831            "{:.1}",
6832            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
6833        )
6834    } else {
6835        "0".to_string()
6836    };
6837    let has_cov = !cov_arr.is_empty();
6838    let mut file_cov_arr: Vec<serde_json::Value> = run
6839        .per_file_records
6840        .iter()
6841        .filter_map(|rec| {
6842            rec.coverage.as_ref().map(|cov| {
6843                let line_pct = if cov.lines_found > 0 {
6844                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
6845                        / 10.0
6846                } else {
6847                    0.0
6848                };
6849                let fn_pct = if cov.functions_found > 0 {
6850                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
6851                        .round()
6852                        / 10.0
6853                } else {
6854                    -1.0
6855                };
6856                serde_json::json!({
6857                    "rel": rec.relative_path,
6858                    "lang": rec.language.map_or("?", |l| l.display_name()),
6859                    "line_pct": line_pct,
6860                    "fn_pct": fn_pct,
6861                    "lhit": cov.lines_hit,
6862                    "lfound": cov.lines_found,
6863                    "fhit": cov.functions_hit,
6864                    "ffound": cov.functions_found,
6865                })
6866            })
6867        })
6868        .collect();
6869    file_cov_arr.sort_by(|a, b| {
6870        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
6871        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
6872        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
6873    });
6874    serde_json::json!({
6875        "totals": {
6876            "test_count": total_tests,
6877            "assertions": t.test_assertion_count,
6878            "suites": t.test_suite_count,
6879            "test_files": test_files,
6880            "total_files": t.files_analyzed,
6881            "density_str": format!("{density:.1}"),
6882            "most_tested": most_tested,
6883            "langs_with_tests": langs.len(),
6884            "cov_line": cov_line,
6885            "cov_fn": cov_fn,
6886            "cov_branch": cov_branch,
6887        },
6888        "lang_tests": lang_tests,
6889        "cov": cov_arr,
6890        "cov_tiers": {"high": high, "mid": mid, "low": low},
6891        "file_cov": file_cov_arr,
6892        "has_coverage": has_cov,
6893        "submodules": {},
6894    })
6895}
6896
6897#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
6898fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
6899    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
6900        .language_summaries
6901        .iter()
6902        .filter(|l| l.test_count > 0)
6903        .collect();
6904    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6905    let lang_tests: Vec<serde_json::Value> = langs
6906        .iter()
6907        .map(|l| {
6908            let d = if l.code_lines > 0 {
6909                l.test_count as f64 / l.code_lines as f64 * 1000.0
6910            } else {
6911                0.0
6912            };
6913            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6914                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6915                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6916        })
6917        .collect();
6918    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
6919    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
6920    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
6921    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
6922    let density = if sub.code_lines > 0 {
6923        total_tests as f64 / sub.code_lines as f64 * 1000.0
6924    } else {
6925        0.0
6926    };
6927    let most_tested = langs.first().map_or_else(
6928        || "\u{2014}".to_string(),
6929        |l| l.language.display_name().to_string(),
6930    );
6931    serde_json::json!({
6932        "totals": {
6933            "test_count": total_tests,
6934            "assertions": total_assertions,
6935            "suites": total_suites,
6936            "test_files": test_files_approx,
6937            "total_files": sub.files_analyzed,
6938            "density_str": format!("{density:.1}"),
6939            "most_tested": most_tested,
6940            "langs_with_tests": langs.len(),
6941            "cov_line": "0",
6942            "cov_fn": "0",
6943            "cov_branch": "0",
6944        },
6945        "lang_tests": lang_tests,
6946        "cov": [],
6947        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
6948        "has_coverage": false,
6949    })
6950}
6951
6952// GET /test-metrics
6953#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
6954#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
6955async fn test_metrics_handler(
6956    // NOSONAR(rust:S3776)
6957    State(state): State<AppState>,
6958    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6959) -> Response {
6960    auto_scan_watched_dirs(&state).await;
6961    let watched_dirs_list: Vec<String> = {
6962        let wd = state.watched_dirs.lock().await;
6963        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6964    };
6965    let latest_run: Option<AnalysisRun> = {
6966        let reg = state.registry.lock().await;
6967        let json_str: Option<String> = reg
6968            .entries
6969            .first()
6970            .and_then(|e| e.json_path.as_ref())
6971            .and_then(|p| std::fs::read_to_string(p).ok());
6972        drop(reg);
6973        json_str
6974            .as_deref()
6975            .and_then(|s| serde_json::from_str(s).ok())
6976    };
6977
6978    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
6979    let _lang_tests_json: String = latest_run.as_ref().map_or_else(
6980        || "[]".to_string(),
6981        |r| {
6982            let mut langs: Vec<&sloc_core::LanguageSummary> = r
6983                .totals_by_language
6984                .iter()
6985                .filter(|l| l.test_count > 0)
6986                .collect();
6987            langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6988            let parts: Vec<String> = langs
6989                .iter()
6990                .map(|l| {
6991                    let name = l.language.display_name().replace('"', "\\\"");
6992                    let density = if l.code_lines > 0 {
6993                        // ratio for density display, precision loss acceptable
6994                        #[allow(clippy::cast_precision_loss)]
6995                        { l.test_count as f64 / l.code_lines as f64 * 1000.0 }
6996                    } else {
6997                        0.0
6998                    };
6999                    format!(
7000                        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
7001                        name = name,
7002                        t = l.test_count,
7003                        a = l.test_assertion_count,
7004                        s = l.test_suite_count,
7005                        c = l.code_lines,
7006                        d = density,
7007                        f = l.files,
7008                    )
7009                })
7010                .collect();
7011            format!("[{}]", parts.join(","))
7012        },
7013    );
7014
7015    // Build coverage chart JSON (per-language avg line coverage %).
7016    let cov_json: String = match &latest_run {
7017        Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
7018            use std::collections::HashMap;
7019            let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
7020            for rec in &r.per_file_records {
7021                if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
7022                    let e = totals.entry(lang.display_name().to_string()).or_default();
7023                    e.0 += u64::from(cov.lines_found);
7024                    e.1 += u64::from(cov.lines_hit);
7025                }
7026            }
7027            let mut pairs: Vec<(String, f64)> = totals
7028                .into_iter()
7029                .filter(|(_, (found, _))| *found > 0)
7030                .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
7031                .collect();
7032            pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7033            let parts: Vec<String> = pairs
7034                .iter()
7035                .map(|(lang, pct)| {
7036                    let name = lang.replace('"', "\\\"");
7037                    format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
7038                })
7039                .collect();
7040            format!("[{}]", parts.join(","))
7041        }
7042        _ => "[]".to_string(),
7043    };
7044
7045    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
7046    let _cov_tier_json: String = match &latest_run {
7047        Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
7048            let mut high = 0u64; // >= 80%
7049            let mut mid = 0u64; // 50-79%
7050            let mut low = 0u64; // < 50%
7051            for rec in &r.per_file_records {
7052                if let Some(cov) = &rec.coverage {
7053                    if cov.lines_found == 0 {
7054                        continue;
7055                    }
7056                    let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
7057                    if pct >= 80.0 {
7058                        high += 1;
7059                    } else if pct >= 50.0 {
7060                        mid += 1;
7061                    } else {
7062                        low += 1;
7063                    }
7064                }
7065            }
7066            format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
7067        }
7068        _ => r#"{"high":0,"mid":0,"low":0}"#.to_string(),
7069    };
7070
7071    let total_tests: u64 = latest_run
7072        .as_ref()
7073        .map_or(0, |r| r.summary_totals.test_count);
7074    let total_assertions: u64 = latest_run
7075        .as_ref()
7076        .map_or(0, |r| r.summary_totals.test_assertion_count);
7077    let total_suites: u64 = latest_run
7078        .as_ref()
7079        .map_or(0, |r| r.summary_totals.test_suite_count);
7080    let total_code: u64 = latest_run
7081        .as_ref()
7082        .map_or(0, |r| r.summary_totals.code_lines);
7083    let workspace_density: f64 = if total_code > 0 {
7084        total_tests as f64 / total_code as f64 * 1000.0
7085    } else {
7086        0.0
7087    };
7088    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
7089        r.totals_by_language
7090            .iter()
7091            .filter(|l| l.test_count > 0)
7092            .count()
7093    });
7094    let most_tested: String = latest_run
7095        .as_ref()
7096        .and_then(|r| {
7097            r.totals_by_language
7098                .iter()
7099                .filter(|l| l.test_count > 0)
7100                .max_by_key(|l| l.test_count)
7101        })
7102        .map_or_else(
7103            || "\u{2014}".to_string(),
7104            |l| l.language.display_name().to_string(),
7105        );
7106    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
7107        r.per_file_records
7108            .iter()
7109            .filter(|f| f.raw_line_categories.test_count > 0)
7110            .count() as u64
7111    });
7112    let total_files_analyzed: u64 = latest_run
7113        .as_ref()
7114        .map_or(0, |r| r.summary_totals.files_analyzed);
7115    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
7116
7117    // Aggregated coverage percentages from summary_totals
7118    let cov_line_pct_str: String = latest_run
7119        .as_ref()
7120        .filter(|r| r.summary_totals.coverage_lines_found > 0)
7121        .map_or_else(
7122            || "0".to_string(),
7123            |r| {
7124                format!(
7125                    "{:.1}",
7126                    r.summary_totals.coverage_lines_hit as f64
7127                        / r.summary_totals.coverage_lines_found as f64
7128                        * 100.0
7129                )
7130            },
7131        );
7132    let cov_fn_pct_str: String = latest_run
7133        .as_ref()
7134        .filter(|r| r.summary_totals.coverage_functions_found > 0)
7135        .map_or_else(
7136            || "0".to_string(),
7137            |r| {
7138                format!(
7139                    "{:.1}",
7140                    r.summary_totals.coverage_functions_hit as f64
7141                        / r.summary_totals.coverage_functions_found as f64
7142                        * 100.0
7143                )
7144            },
7145        );
7146    let cov_branch_pct_str: String = latest_run
7147        .as_ref()
7148        .filter(|r| r.summary_totals.coverage_branches_found > 0)
7149        .map_or_else(
7150            || "0".to_string(),
7151            |r| {
7152                format!(
7153                    "{:.1}",
7154                    r.summary_totals.coverage_branches_hit as f64
7155                        / r.summary_totals.coverage_branches_found as f64
7156                        * 100.0
7157                )
7158            },
7159        );
7160
7161    let cov_no_data_notice = if has_coverage {
7162        String::new()
7163    } else {
7164        String::from(
7165            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
7166<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>
7167<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
7168  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
7169  <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>
7170  <span style="color:var(--muted);font-size:12px;">&middot;</span>
7171  <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>
7172  <span style="color:var(--muted);font-size:12px;">&middot;</span>
7173  <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>
7174</div>
7175<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
7176</div>"#,
7177        )
7178    };
7179
7180    let workspace_density_str = format!("{workspace_density:.1}");
7181    let nonce = &csp_nonce;
7182    let version = env!("CARGO_PKG_VERSION");
7183
7184    let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7185        r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7186            .to_string()
7187    } else {
7188        watched_dirs_list
7189            .iter()
7190            .fold(String::new(), |mut s, d| {
7191                use std::fmt::Write as _;
7192                let escaped =
7193                    d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
7194                write!(
7195                    s,
7196                    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>"#
7197                ).expect("write to String is infallible");
7198                s
7199            })
7200    };
7201    let watched_dirs_html = format!(
7202        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>"#
7203    );
7204
7205    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
7206    let scope_data_json: String = {
7207        let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
7208        scope_map.insert(
7209            "__all__".to_string(),
7210            latest_run.as_ref().map_or_else(
7211                || {
7212                    serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
7213                        "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
7214                        "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
7215                        "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
7216                        "has_coverage":false,"submodules":{}})
7217                },
7218                build_test_scope_entry,
7219            ),
7220        );
7221        let all_roots: Vec<String> = {
7222            let reg = state.registry.lock().await;
7223            let mut seen = std::collections::BTreeSet::new();
7224            reg.entries
7225                .iter()
7226                .flat_map(|e| e.input_roots.iter().cloned())
7227                .filter(|r| seen.insert(r.clone()))
7228                .collect()
7229        };
7230        for root in &all_roots {
7231            let run_for_root: Option<AnalysisRun> = {
7232                let reg = state.registry.lock().await;
7233                let json_str = reg
7234                    .entries
7235                    .iter()
7236                    .find(|e| e.input_roots.iter().any(|r| r == root))
7237                    .and_then(|e| e.json_path.as_ref())
7238                    .and_then(|p| std::fs::read_to_string(p).ok());
7239                drop(reg);
7240                json_str
7241                    .as_deref()
7242                    .and_then(|s| serde_json::from_str(s).ok())
7243            };
7244            if let Some(ref run) = run_for_root {
7245                let mut root_entry = build_test_scope_entry(run);
7246                if !run.submodule_summaries.is_empty() {
7247                    let subs: serde_json::Map<String, serde_json::Value> = run
7248                        .submodule_summaries
7249                        .iter()
7250                        .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
7251                        .collect();
7252                    root_entry["submodules"] = serde_json::Value::Object(subs);
7253                }
7254                scope_map.insert(root.clone(), root_entry);
7255            }
7256        }
7257        serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
7258    };
7259
7260    let html = format!(
7261        r#"<!doctype html>
7262<html lang="en">
7263<head>
7264  <meta charset="utf-8" />
7265  <meta name="viewport" content="width=device-width, initial-scale=1" />
7266  <title>OxideSLOC | Test Metrics</title>
7267  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7268  <style nonce="{nonce}">
7269    :root {{
7270      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7271      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7272      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7273      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7274      --info-bg:#eef3ff; --info-text:#4467d8;
7275    }}
7276    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7277    *{{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);}}
7278    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7279    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7280    .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;}}
7281    @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));}}}}
7282    .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);}}
7283    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7284    .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));}}
7285    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7286    .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;}}
7287    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7288    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7289    @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; }} }}
7290    .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;}}
7291    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7292    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7293    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7294    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7295    .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;}}
7296    .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;}}
7297    .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;}}
7298    .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;}}
7299    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7300    .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);}}
7301    .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;}}
7302    .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;}}
7303    .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;}}
7304    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7305    .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;}}
7306    .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);}}
7307    .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;}}
7308    .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;}}
7309    .tz-select:focus{{border-color:var(--oxide);}}
7310    .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
7311    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7312    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7313    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7314    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7315    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7316    .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;}}
7317    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7318    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7319    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7320    .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;}}
7321    .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;}}
7322    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7323    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7324    .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);}}
7325    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
7326    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
7327    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
7328    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
7329    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
7330    .chart-canvas-wrap{{position:relative;height:280px;}}
7331    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
7332    .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;}}
7333    .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;}}
7334    .data-table tr:last-child td{{border-bottom:none;}}
7335    .data-table tbody tr:hover td{{background:var(--surface-2);}}
7336    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
7337    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
7338    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
7339    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
7340    .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;}}
7341    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
7342    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
7343    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
7344    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
7345    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
7346    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
7347    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
7348    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
7349    .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;}}
7350    .chart-select:focus{{border-color:var(--accent);}}
7351    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7352    .trend-canvas-wrap{{position:relative;height:260px;}}
7353    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7354    .site-footer a{{color:var(--muted);}}
7355    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
7356    .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;}}
7357    .btn:hover{{background:var(--surface-2);}}
7358    .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;}}
7359    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7360    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
7361    .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;}}
7362    .scope-sel:focus{{border-color:var(--accent);}}
7363    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
7364    .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;}}
7365    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7366    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7367    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7368    .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;}}
7369    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7370    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7371    .watched-chip-rm:hover{{color:var(--oxide);}}
7372    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7373    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7374    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7375    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
7376    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
7377    .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;}}
7378    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
7379    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
7380    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
7381    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
7382    .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;}}
7383    .cov-file-search:focus{{border-color:var(--accent);}}
7384    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
7385    .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;}}
7386    body.dark-theme .cov-file-search{{background:var(--surface);}}
7387  </style>
7388</head>
7389<body>
7390  <div class="background-watermarks" aria-hidden="true">
7391    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7392    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7393    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7394    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7395    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7396    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7397  </div>
7398  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7399  <div class="top-nav">
7400    <div class="top-nav-inner">
7401      <a class="brand" href="/">
7402        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7403        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
7404      </a>
7405      <div class="nav-right">
7406        <a class="nav-pill" href="/">Home</a>
7407        <div class="nav-dropdown">
7408          <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>
7409          <div class="nav-dropdown-menu">
7410            <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>
7411          </div>
7412        </div>
7413        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7414        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
7415        <div class="nav-dropdown">
7416          <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>
7417          <div class="nav-dropdown-menu">
7418            <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>
7419          </div>
7420        </div>
7421        <div class="server-status-wrap">
7422          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
7423          <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>
7424        </div>
7425        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7426          <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>
7427        </button>
7428        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7429          <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>
7430          <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>
7431        </button>
7432      </div>
7433    </div>
7434  </div>
7435
7436  <div class="page">
7437    {watched_dirs_html}
7438    <div class="scope-bar">
7439      <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>
7440      <span class="scope-label">Scope</span>
7441      <div class="scope-sel-wrap">
7442        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
7443        <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);">
7444          <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>
7445          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
7446        </div>
7447      </div>
7448    </div>
7449    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7450      <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>
7451      <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>
7452      <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>
7453      <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>
7454    </div>
7455    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7456      <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>
7457      <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>
7458      <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>
7459      <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>
7460    </div>
7461
7462    <div class="panel">
7463      <h1>Test Metrics</h1>
7464      <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>
7465
7466      <div class="chart-row">
7467        <div class="chart-box">
7468          <div class="chart-box-title">Test Definitions by Language</div>
7469          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
7470        </div>
7471        <div class="chart-box">
7472          <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
7473          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
7474        </div>
7475      </div>
7476
7477      <div class="section-header">Language Breakdown</div>
7478      {cov_no_data_notice}
7479      <div style="overflow-x:auto;">
7480        <table class="data-table" id="lang-table">
7481          <thead><tr>
7482            <th>Language</th>
7483            <th class="num">Test Fns</th>
7484            <th class="num">Assertions</th>
7485            <th class="num">Suites</th>
7486            <th class="num">Code Lines</th>
7487            <th class="num">Files</th>
7488            <th class="num">Density / 1K</th>
7489            <th>Relative Density</th>
7490          </tr></thead>
7491          <tbody id="lang-tbody"></tbody>
7492        </table>
7493      </div>
7494    </div>
7495
7496    <div class="panel" id="cov-panel" style="display:none;">
7497      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
7498      <div class="cov-gauge-row" id="cov-gauges">
7499        <div class="cov-gauge-card">
7500          <div class="cov-gauge-label">Line Coverage</div>
7501          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
7502          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
7503          <div class="cov-gauge-sub">Lines hit / instrumented</div>
7504        </div>
7505        <div class="cov-gauge-card">
7506          <div class="cov-gauge-label">Function Coverage</div>
7507          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
7508          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
7509          <div class="cov-gauge-sub">Functions hit / found</div>
7510        </div>
7511        <div class="cov-gauge-card">
7512          <div class="cov-gauge-label">Branch Coverage</div>
7513          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
7514          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
7515          <div class="cov-gauge-sub">Branches hit / found</div>
7516        </div>
7517      </div>
7518      <div class="chart-row">
7519        <div class="chart-box">
7520          <div class="chart-box-title">Line Coverage % by Language</div>
7521          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
7522        </div>
7523        <div class="chart-box">
7524          <div class="chart-box-title">Coverage Tier Distribution</div>
7525          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
7526        </div>
7527      </div>
7528
7529      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
7530      <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>
7531      <div class="cov-file-toolbar">
7532        <div class="cov-filter-tabs" id="cov-filter-tabs">
7533          <button class="cov-tab active" data-tier="all">All</button>
7534          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
7535          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
7536          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
7537          <button class="cov-tab" data-tier="high">High (≥80%)</button>
7538        </div>
7539        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
7540      </div>
7541      <div style="overflow-x:auto;">
7542        <table class="data-table" id="cov-file-table">
7543          <thead><tr>
7544            <th>File</th>
7545            <th>Lang</th>
7546            <th class="num">Line %</th>
7547            <th class="num">Lines Hit / Found</th>
7548            <th class="num">Fn %</th>
7549            <th class="num">Fns Hit / Found</th>
7550          </tr></thead>
7551          <tbody id="cov-file-tbody"></tbody>
7552        </table>
7553      </div>
7554      <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>
7555      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
7556    </div>
7557
7558    <div class="panel">
7559      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
7560      <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
7561      <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
7562      <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
7563    </div>
7564  </div>
7565
7566  <footer class="site-footer">
7567    oxide-sloc v{version} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
7568    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7569    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7570    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7571    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
7572  </footer>
7573
7574  <script nonce="{nonce}">
7575  (function() {{
7576    // Theme
7577    var b = document.body;
7578    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7579    var tgl = document.getElementById('theme-toggle');
7580    if (tgl) tgl.addEventListener('click', function() {{
7581      var d = b.classList.toggle('dark-theme');
7582      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7583    }});
7584
7585    // Watermarks
7586    (function() {{
7587      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7588      if (!wms.length) return;
7589      var placed = [];
7590      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;}}
7591      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];}}
7592      var half=Math.floor(wms.length/2);
7593      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;}});
7594    }})();
7595
7596    // Code particles
7597    (function() {{
7598      var container = document.getElementById('code-particles');
7599      if (!container) return;
7600      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
7601      for (var i = 0; i < 36; i++) {{
7602        (function(idx) {{
7603          var el = document.createElement('span');
7604          el.className = 'code-particle';
7605          el.textContent = snippets[idx % snippets.length];
7606          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7607          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7608          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7609          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';
7610          container.appendChild(el);
7611        }})(i);
7612      }}
7613    }})();
7614
7615    // Settings modal
7616    (function() {{
7617      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'}}];
7618      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);}});}}
7619      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7620      var btn=document.getElementById('settings-btn');if(!btn)return;
7621      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7622      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>';
7623      document.body.appendChild(m);
7624      var g=document.getElementById('scheme-grid');
7625      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);}});
7626      var cl=document.getElementById('settings-close');
7627      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');}});
7628      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7629      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7630    }})();
7631
7632    // Watched folder picker
7633    (function() {{
7634      var btn = document.getElementById('add-watched-btn');
7635      if (!btn) return;
7636      btn.addEventListener('click', function() {{
7637        fetch('/pick-directory?kind=reports')
7638          .then(function(r) {{ return r.json(); }})
7639          .then(function(data) {{
7640            if (!data.cancelled && data.selected_path) {{
7641              var form = document.createElement('form');
7642              form.method = 'POST';
7643              form.action = '/watched-dirs/add';
7644              var ri = document.createElement('input');
7645              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7646              var fi = document.createElement('input');
7647              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7648              form.appendChild(ri); form.appendChild(fi);
7649              document.body.appendChild(form);
7650              form.submit();
7651            }}
7652          }})
7653          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7654      }});
7655    }})();
7656  }})();
7657  </script>
7658
7659  <script src="/static/chart.js" nonce="{nonce}"></script>
7660  <script nonce="{nonce}">
7661  (function() {{
7662    var SCOPE_DATA = {scope_data_json};
7663    var currentRoot = '__all__';
7664    var currentSub  = '';
7665    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
7666    var ALL_CHARTS = [];
7667
7668    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();}}
7669    function fmtFull(n){{return Number(n).toLocaleString();}}
7670    function isDark(){{return document.body.classList.contains('dark-theme');}}
7671    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
7672    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
7673    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
7674
7675    function getDataset() {{
7676      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7677      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
7678      return r;
7679    }}
7680    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
7681
7682    function renderTestCharts(D) {{
7683      testsChart = destroyChart(testsChart);
7684      densityChart = destroyChart(densityChart);
7685      if (!D || !D.length) return;
7686      var top15 = D.slice(0, 15);
7687      var canvas1 = document.getElementById('canvas-tests');
7688      if (canvas1) {{
7689        testsChart = new Chart(canvas1, {{
7690          type: 'bar',
7691          data: {{
7692            labels: top15.map(function(d){{ return d.lang; }}),
7693            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
7694          }},
7695          options: {{
7696            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7697            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
7698            scales: {{
7699              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
7700              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7701            }}
7702          }}
7703        }});
7704        ALL_CHARTS.push(testsChart);
7705      }}
7706      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
7707      var canvas2 = document.getElementById('canvas-density');
7708      if (canvas2) {{
7709        densityChart = new Chart(canvas2, {{
7710          type: 'bar',
7711          data: {{
7712            labels: topD.map(function(d){{ return d.lang; }}),
7713            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 }}]
7714          }},
7715          options: {{
7716            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7717            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
7718            scales: {{
7719              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
7720              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7721            }}
7722          }}
7723        }});
7724        ALL_CHARTS.push(densityChart);
7725      }}
7726    }}
7727
7728    function renderCovCharts(covD, tiers) {{
7729      covChart = destroyChart(covChart);
7730      tierChart = destroyChart(tierChart);
7731      var covCanvas = document.getElementById('canvas-cov');
7732      if (covCanvas && covD && covD.length) {{
7733        covChart = new Chart(covCanvas, {{
7734          type: 'bar',
7735          data: {{
7736            labels: covD.map(function(d){{ return d.lang; }}),
7737            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 }}]
7738          }},
7739          options: {{
7740            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7741            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
7742            scales: {{
7743              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
7744              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7745            }}
7746          }}
7747        }});
7748        ALL_CHARTS.push(covChart);
7749      }}
7750      var tierCanvas = document.getElementById('canvas-cov-tiers');
7751      if (tierCanvas && tiers) {{
7752        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
7753        tierChart = new Chart(tierCanvas, {{
7754          type: 'doughnut',
7755          data: {{
7756            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
7757            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
7758          }},
7759          options: {{
7760            responsive: true, maintainAspectRatio: false, cutout: '62%',
7761            plugins: {{
7762              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
7763              tooltip: {{ callbacks: {{ label: function(ctx) {{
7764                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
7765                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
7766              }} }} }}
7767            }}
7768          }}
7769        }});
7770        ALL_CHARTS.push(tierChart);
7771      }}
7772    }}
7773
7774    function buildLangTable(D) {{
7775      var tbody = document.getElementById('lang-tbody');
7776      if (!tbody) return;
7777      if (!D || !D.length) {{
7778        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>';
7779        return;
7780      }}
7781      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
7782      tbody.innerHTML = D.map(function(d) {{
7783        var barW = Math.round(d.density / maxDensity * 120);
7784        return '<tr>' +
7785          '<td><strong>' + d.lang + '</strong></td>' +
7786          '<td class="num">' + fmt(d.tests) + '</td>' +
7787          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
7788          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
7789          '<td class="num">' + fmt(d.code) + '</td>' +
7790          '<td class="num">' + fmt(d.files) + '</td>' +
7791          '<td class="num">' + d.density.toFixed(2) + '</td>' +
7792          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
7793          '</tr>';
7794      }}).join('');
7795    }}
7796
7797    var covFileData = [];
7798    var covFileTier = 'all';
7799    var covFileSearch = '';
7800
7801    function pctBadge(pct) {{
7802      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
7803      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
7804      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
7805    }}
7806
7807    function buildCovFileTable() {{
7808      var tbody = document.getElementById('cov-file-tbody');
7809      var empty = document.getElementById('cov-file-empty');
7810      var count = document.getElementById('cov-file-count');
7811      if (!tbody) return;
7812      var srch = covFileSearch.toLowerCase();
7813      var filtered = covFileData.filter(function(f) {{
7814        if (covFileTier === 'zero' && f.line_pct > 0) return false;
7815        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
7816        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
7817        if (covFileTier === 'high' && f.line_pct < 80) return false;
7818        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
7819        return true;
7820      }});
7821      if (!filtered.length) {{
7822        tbody.innerHTML = '';
7823        if (empty) empty.style.display = '';
7824        if (count) count.textContent = '';
7825        return;
7826      }}
7827      if (empty) empty.style.display = 'none';
7828      var shown = Math.min(filtered.length, 500);
7829      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
7830      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
7831        var fnCol = f.fn_pct < 0
7832          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
7833          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
7834        return '<tr>' +
7835          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
7836          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
7837          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
7838          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
7839          fnCol +
7840          '</tr>';
7841      }}).join('');
7842    }}
7843
7844    (function() {{
7845      var tabs = document.getElementById('cov-filter-tabs');
7846      if (tabs) {{
7847        tabs.addEventListener('click', function(e) {{
7848          var btn = e.target.closest('.cov-tab');
7849          if (!btn) return;
7850          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
7851          btn.classList.add('active');
7852          covFileTier = btn.getAttribute('data-tier');
7853          buildCovFileTable();
7854        }});
7855      }}
7856      var srch = document.getElementById('cov-file-search');
7857      if (srch) {{
7858        srch.addEventListener('input', function() {{
7859          covFileSearch = this.value;
7860          buildCovFileTable();
7861        }});
7862      }}
7863    }})();
7864
7865    function updateCovGauges(t) {{
7866      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
7867      var el;
7868      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
7869      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
7870      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
7871      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
7872      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
7873      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
7874    }}
7875
7876    function applyScope() {{
7877      var d = getDataset();
7878      var t = d.totals;
7879      var el;
7880      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
7881      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
7882      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
7883      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
7884      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
7885      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
7886      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
7887      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
7888      renderTestCharts(d.lang_tests);
7889      buildLangTable(d.lang_tests);
7890      var covPanel = document.getElementById('cov-panel');
7891      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
7892      if (d.has_coverage) {{
7893        renderCovCharts(d.cov, d.cov_tiers);
7894        updateCovGauges(t);
7895        covFileData = d.file_cov || [];
7896        covFileTier = 'all';
7897        covFileSearch = '';
7898        var tabs = document.getElementById('cov-filter-tabs');
7899        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
7900        var srch = document.getElementById('cov-file-search');
7901        if (srch) srch.value = '';
7902        buildCovFileTable();
7903      }}
7904      loadTrend();
7905    }}
7906
7907    // Populate scope-root-sel from SCOPE_DATA keys
7908    (function() {{
7909      var sel = document.getElementById('scope-root-sel');
7910      if (!sel) return;
7911      Object.keys(SCOPE_DATA).forEach(function(k) {{
7912        if (k === '__all__') return;
7913        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
7914      }});
7915    }})();
7916
7917    document.getElementById('scope-root-sel').addEventListener('change', function() {{
7918      currentRoot = this.value;
7919      currentSub = '';
7920      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7921      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
7922      var subWrap = document.getElementById('scope-sub-wrap');
7923      var subSel  = document.getElementById('scope-sub-sel');
7924      subSel.innerHTML = '<option value="">Entire project</option>';
7925      if (subNames.length) {{
7926        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
7927        subWrap.style.display = 'flex';
7928      }} else {{
7929        subWrap.style.display = 'none';
7930      }}
7931      applyScope();
7932    }});
7933
7934    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
7935      currentSub = this.value;
7936      applyScope();
7937    }});
7938
7939    function buildTrend(data) {{
7940      var trendCanvas = document.getElementById('canvas-trend');
7941      var trendEmpty  = document.getElementById('trend-empty');
7942      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
7943      pts = pts.slice().reverse();
7944      if (!pts.length) {{
7945        if (trendCanvas) trendCanvas.style.display = 'none';
7946        if (trendEmpty) trendEmpty.style.display = '';
7947        return;
7948      }}
7949      if (trendCanvas) trendCanvas.style.display = '';
7950      if (trendEmpty) trendEmpty.style.display = 'none';
7951      trendChart = destroyChart(trendChart);
7952      if (!trendCanvas) return;
7953      trendChart = new Chart(trendCanvas, {{
7954        type: 'line',
7955        data: {{
7956          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
7957          datasets: [{{
7958            label: 'Test Definitions',
7959            data: pts.map(function(d){{ return d.test_count; }}),
7960            borderColor: '#C45C10',
7961            backgroundColor: 'rgba(196,92,16,0.10)',
7962            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
7963            pointRadius: 5, fill: true, tension: 0.3
7964          }}]
7965        }},
7966        options: {{
7967          responsive: true, maintainAspectRatio: false,
7968          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
7969          scales: {{
7970            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
7971            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
7972          }}
7973        }}
7974      }});
7975      ALL_CHARTS.push(trendChart);
7976    }}
7977
7978    function loadTrend() {{
7979      var url = '/api/metrics/history?limit=100';
7980      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
7981      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
7982        buildTrend(data);
7983      }}).catch(function(){{
7984        var trendEmpty = document.getElementById('trend-empty');
7985        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
7986      }});
7987    }}
7988
7989    // Re-render charts on theme toggle
7990    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
7991      setTimeout(function() {{
7992        ALL_CHARTS.forEach(function(c) {{
7993          if (c && c.options && c.options.scales) {{
7994            Object.values(c.options.scales).forEach(function(ax) {{
7995              if (ax.grid) ax.grid.color = clr();
7996              if (ax.ticks) ax.ticks.color = txtClr();
7997            }});
7998            c.update();
7999          }}
8000        }});
8001      }}, 80);
8002    }});
8003
8004    applyScope();
8005  }})();
8006  </script>
8007</body>
8008</html>"#,
8009    );
8010    Html(html).into_response()
8011}
8012
8013// ── Embeddable widget ─────────────────────────────────────────────────────────
8014// Protected. Returns a self-contained HTML page suitable for iframing inside
8015// Jenkins build summaries, Confluence iframe macros, or Jira panels.
8016//
8017// GET /embed/summary?run_id=<uuid>&theme=dark
8018
8019#[derive(Deserialize)]
8020struct EmbedQuery {
8021    run_id: Option<String>,
8022    theme: Option<String>,
8023}
8024
8025async fn embed_handler(
8026    State(state): State<AppState>,
8027    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8028    Query(query): Query<EmbedQuery>,
8029) -> Response {
8030    let entry = {
8031        let reg = state.registry.lock().await;
8032        query.run_id.as_ref().map_or_else(
8033            || reg.entries.first().cloned(),
8034            |id| reg.find_by_run_id(id).cloned(),
8035        )
8036    };
8037
8038    let Some(entry) = entry else {
8039        return Html(
8040            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
8041                .to_string(),
8042        )
8043        .into_response();
8044    };
8045
8046    let dark = query.theme.as_deref() == Some("dark");
8047    let languages: Vec<(String, u64, u64)> = entry
8048        .json_path
8049        .as_ref()
8050        .and_then(|p| read_json(p).ok())
8051        .map(|run| {
8052            run.totals_by_language
8053                .iter()
8054                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
8055                .collect()
8056        })
8057        .unwrap_or_default();
8058
8059    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
8060}
8061
8062fn render_embed_widget(
8063    entry: &RegistryEntry,
8064    languages: &[(String, u64, u64)],
8065    dark: bool,
8066    csp_nonce: &str,
8067) -> String {
8068    let s = &entry.summary;
8069    let total = s.code_lines + s.comment_lines + s.blank_lines;
8070    let code_pct = s
8071        .code_lines
8072        .checked_mul(100)
8073        .and_then(|n| n.checked_div(total))
8074        .unwrap_or(0);
8075
8076    let (bg, fg, surface, muted, border) = if dark {
8077        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
8078    } else {
8079        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
8080    };
8081
8082    let mut lang_rows = String::new();
8083    for (name, files, code) in languages {
8084        write!(
8085            lang_rows,
8086            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
8087            escape_html(name),
8088            format_number(*files),
8089            format_number(*code),
8090        )
8091        .ok();
8092    }
8093
8094    let lang_table = if lang_rows.is_empty() {
8095        String::new()
8096    } else {
8097        format!(
8098            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
8099        )
8100    };
8101
8102    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
8103    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
8104    let project_esc = escape_html(&entry.project_label);
8105    let code_lines = format_number(s.code_lines);
8106    let comment_lines = format_number(s.comment_lines);
8107    let files = format_number(s.files_analyzed);
8108    let code_raw = s.code_lines;
8109    let comment_raw = s.comment_lines;
8110    let blank_raw = s.blank_lines;
8111
8112    format!(
8113        r#"<!doctype html>
8114<html lang="en">
8115<head>
8116  <meta charset="utf-8">
8117  <meta name="viewport" content="width=device-width,initial-scale=1">
8118  <title>OxideSLOC &mdash; {project_esc}</title>
8119  <script src="/static/chart.js"></script>
8120  <style nonce="{csp_nonce}">
8121    *{{box-sizing:border-box;margin:0;padding:0}}
8122    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
8123    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
8124    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
8125    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
8126    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
8127    .card .v{{font-size:18px;font-weight:700}}
8128    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
8129    .row{{display:flex;gap:12px;align-items:flex-start}}
8130    .pie{{width:120px;height:120px;flex-shrink:0}}
8131    .lt{{border-collapse:collapse;width:100%;flex:1}}
8132    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
8133    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
8134    .n{{text-align:right}}
8135    .footer{{margin-top:10px;color:{muted};font-size:10px}}
8136  </style>
8137</head>
8138<body>
8139  <h2>{project_esc}</h2>
8140  <div class="sub">{timestamp} &middot; run {run_short}</div>
8141  <div class="cards">
8142    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
8143    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
8144    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
8145    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
8146  </div>
8147  <div class="row">
8148    <canvas class="pie" id="c"></canvas>
8149    {lang_table}
8150  </div>
8151  <div class="footer">oxide-sloc</div>
8152  <script nonce="{csp_nonce}">
8153    new Chart(document.getElementById('c'),{{
8154      type:'doughnut',
8155      data:{{
8156        labels:['Code','Comments','Blank'],
8157        datasets:[{{
8158          data:[{code_raw},{comment_raw},{blank_raw}],
8159          backgroundColor:['#4a78ee','#b35428','#aaa'],
8160          borderWidth:0
8161        }}]
8162      }},
8163      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
8164    }});
8165  </script>
8166</body>
8167</html>"#
8168    )
8169}
8170
8171#[allow(clippy::too_many_arguments)]
8172fn persist_run_artifacts(
8173    run: &sloc_core::AnalysisRun,
8174    report_html: &str,
8175    run_dir: &Path,
8176    generate_json: bool,
8177    generate_html: bool,
8178    generate_pdf: bool,
8179    report_title: &str,
8180    file_stem: &str,
8181    result_context: RunResultContext,
8182) -> Result<(RunArtifacts, PendingPdf)> {
8183    fs::create_dir_all(run_dir)
8184        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
8185
8186    let mut html_path = None;
8187    let mut pdf_path = None;
8188    let mut json_path = None;
8189    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
8190
8191    if generate_html {
8192        let path = run_dir.join(format!("report_{file_stem}.html"));
8193        fs::write(&path, report_html)
8194            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
8195        html_path = Some(path);
8196    }
8197
8198    if generate_json {
8199        let path = run_dir.join(format!("result_{file_stem}.json"));
8200        let json = serde_json::to_string_pretty(run)
8201            .context("failed to serialize analysis run to JSON")?;
8202        fs::write(&path, json)
8203            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
8204        json_path = Some(path);
8205    }
8206
8207    if generate_pdf {
8208        let source_html_path = if let Some(existing) = html_path.as_ref() {
8209            existing.clone()
8210        } else {
8211            let temp_html = run_dir.join("_report_rendered.html");
8212            fs::write(&temp_html, report_html).with_context(|| {
8213                format!(
8214                    "failed to write temporary HTML report to {}",
8215                    temp_html.display()
8216                )
8217            })?;
8218            temp_html
8219        };
8220
8221        let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
8222        let cleanup_src = !generate_html;
8223        pdf_path = Some(pdf_dest.clone());
8224        pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
8225    }
8226
8227    let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
8228
8229    Ok((
8230        RunArtifacts {
8231            output_dir: run_dir.to_path_buf(),
8232            html_path,
8233            pdf_path,
8234            json_path,
8235            scan_config_path,
8236            report_title: report_title.to_string(),
8237            result_context,
8238        },
8239        pending_pdf,
8240    ))
8241}
8242
8243/// Find a scan-config JSON file in `dir`, checking both the legacy fixed name and
8244/// the current `scan-config_<stem>.json` pattern for backwards compatibility.
8245fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
8246    let exact = dir.join("scan-config.json");
8247    if exact.exists() {
8248        return Some(exact);
8249    }
8250    fs::read_dir(dir).ok().and_then(|entries| {
8251        entries
8252            .filter_map(std::result::Result::ok)
8253            .find(|e| {
8254                let name = e.file_name();
8255                let name = name.to_string_lossy();
8256                name.starts_with("scan-config") && name.ends_with(".json")
8257            })
8258            .map(|e| e.path())
8259    })
8260}
8261
8262// ── Config export / import ────────────────────────────────────────────────────
8263
8264async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
8265    let toml_str = match toml::to_string_pretty(&state.base_config) {
8266        Ok(s) => s,
8267        Err(e) => {
8268            return (
8269                StatusCode::INTERNAL_SERVER_ERROR,
8270                format!("serialization error: {e}"),
8271            )
8272                .into_response();
8273        }
8274    };
8275    (
8276        [
8277            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
8278            (
8279                header::CONTENT_DISPOSITION,
8280                "attachment; filename=\".oxide-sloc.toml\"",
8281            ),
8282        ],
8283        toml_str,
8284    )
8285        .into_response()
8286}
8287
8288#[derive(Deserialize)]
8289struct ImportConfigBody {
8290    toml: String,
8291}
8292
8293async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
8294    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
8295        Ok(config) => {
8296            if let Err(e) = config.validate() {
8297                return (
8298                    StatusCode::UNPROCESSABLE_ENTITY,
8299                    Json(serde_json::json!({ "error": e.to_string() })),
8300                )
8301                    .into_response();
8302            }
8303            Json(serde_json::json!({ "ok": true, "config": config })).into_response()
8304        }
8305        Err(e) => (
8306            StatusCode::BAD_REQUEST,
8307            Json(serde_json::json!({ "error": format!("TOML parse error: {e}") })),
8308        )
8309            .into_response(),
8310    }
8311}
8312
8313// ── Scan profiles API ─────────────────────────────────────────────────────────
8314
8315async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
8316    let store = state.scan_profiles.lock().await;
8317    Json(serde_json::json!({ "profiles": store.profiles }))
8318}
8319
8320#[derive(Deserialize)]
8321struct SaveScanProfileBody {
8322    name: String,
8323    params: serde_json::Value,
8324}
8325
8326async fn api_save_scan_profile(
8327    State(state): State<AppState>,
8328    Json(body): Json<SaveScanProfileBody>,
8329) -> impl IntoResponse {
8330    if body.name.trim().is_empty() {
8331        return (
8332            StatusCode::BAD_REQUEST,
8333            Json(serde_json::json!({ "error": "name must not be empty" })),
8334        )
8335            .into_response();
8336    }
8337
8338    let id = uuid::Uuid::new_v4().to_string();
8339    let profile = ScanProfile {
8340        id: id.clone(),
8341        name: body.name.trim().to_string(),
8342        created_at: chrono::Utc::now().to_rfc3339(),
8343        params: body.params,
8344    };
8345
8346    let mut store = state.scan_profiles.lock().await;
8347    store.profiles.push(profile);
8348    if let Err(e) = store.save(&state.scan_profiles_path) {
8349        tracing::warn!("failed to persist scan profiles: {e}");
8350    }
8351    drop(store);
8352
8353    (
8354        StatusCode::CREATED,
8355        Json(serde_json::json!({ "ok": true, "id": id })),
8356    )
8357        .into_response()
8358}
8359
8360async fn api_delete_scan_profile(
8361    State(state): State<AppState>,
8362    AxumPath(id): AxumPath<String>,
8363) -> impl IntoResponse {
8364    let mut store = state.scan_profiles.lock().await;
8365    let before = store.profiles.len();
8366    store.profiles.retain(|p| p.id != id);
8367    if store.profiles.len() == before {
8368        drop(store);
8369        return (
8370            StatusCode::NOT_FOUND,
8371            Json(serde_json::json!({ "error": "profile not found" })),
8372        )
8373            .into_response();
8374    }
8375    if let Err(e) = store.save(&state.scan_profiles_path) {
8376        tracing::warn!("failed to persist scan profiles: {e}");
8377    }
8378    drop(store);
8379    Json(serde_json::json!({ "ok": true })).into_response()
8380}
8381
8382fn resolve_output_root(raw: Option<&str>) -> PathBuf {
8383    let value = raw.unwrap_or("out/web").trim();
8384    let path = if value.is_empty() {
8385        PathBuf::from("out/web")
8386    } else {
8387        PathBuf::from(value)
8388    };
8389
8390    if path.is_absolute() {
8391        path
8392    } else {
8393        workspace_root().join(path)
8394    }
8395}
8396
8397/// Derive the directory that holds remote-repo clones from the output root.
8398fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
8399    std::env::var("SLOC_GIT_CLONES_DIR")
8400        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
8401}
8402
8403/// Build a deterministic filesystem path for a cloned remote repository.
8404/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
8405pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
8406    let safe: String = repo_url
8407        .chars()
8408        .map(|c| {
8409            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
8410                c
8411            } else {
8412                '_'
8413            }
8414        })
8415        .take(80)
8416        .collect();
8417    clones_dir.join(safe)
8418}
8419
8420/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
8421/// Runs synchronously — call from `tokio::task::spawn_blocking`.
8422pub(crate) fn scan_path_to_artifacts(
8423    scan_path: &Path,
8424    base_config: &AppConfig,
8425    label: &str,
8426) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
8427    let mut config = base_config.clone();
8428    config.discovery.root_paths = vec![scan_path.to_path_buf()];
8429    label.clone_into(&mut config.reporting.report_title);
8430    let run = analyze(&config, "git", None)?;
8431    let html = render_html(&run)?;
8432    let run_id = run.tool.run_id.clone();
8433    let project_label = sanitize_project_label(label);
8434    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8435    let file_stem = {
8436        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
8437        if commit.is_empty() {
8438            project_label
8439        } else {
8440            format!("{project_label}_{commit}")
8441        }
8442    };
8443    let (artifacts, _pending_pdf) = persist_run_artifacts(
8444        &run,
8445        &html,
8446        &output_dir,
8447        true,
8448        true,
8449        false,
8450        label,
8451        &file_stem,
8452        RunResultContext::default(),
8453    )?;
8454    Ok((run_id, artifacts, run))
8455}
8456
8457/// Re-spawn background poll tasks for any polling schedules saved to disk.
8458async fn restart_poll_schedules(state: &AppState) {
8459    let store = state.schedules.lock().await;
8460    let poll_schedules: Vec<_> = store
8461        .schedules
8462        .iter()
8463        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
8464        .cloned()
8465        .collect();
8466    drop(store);
8467    for schedule in poll_schedules {
8468        let interval = schedule.interval_secs.unwrap_or(300);
8469        let st = state.clone();
8470        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
8471    }
8472}
8473
8474fn split_patterns(raw: Option<&str>) -> Vec<String> {
8475    raw.unwrap_or("")
8476        .lines()
8477        .flat_map(|line| line.split(','))
8478        .map(str::trim)
8479        .filter(|part| !part.is_empty())
8480        .map(ToOwned::to_owned)
8481        .collect()
8482}
8483
8484fn build_sub_run(
8485    parent: &AnalysisRun,
8486    sub: &sloc_core::SubmoduleSummary,
8487    parent_path: &str,
8488) -> AnalysisRun {
8489    let sub_files: Vec<_> = parent
8490        .per_file_records
8491        .iter()
8492        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8493        .cloned()
8494        .collect();
8495    let mut config = parent.effective_configuration.clone();
8496    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
8497    AnalysisRun {
8498        tool: parent.tool.clone(),
8499        environment: parent.environment.clone(),
8500        effective_configuration: config,
8501        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
8502        summary_totals: SummaryTotals {
8503            files_considered: sub.files_analyzed,
8504            files_analyzed: sub.files_analyzed,
8505            files_skipped: 0,
8506            total_physical_lines: sub.total_physical_lines,
8507            code_lines: sub.code_lines,
8508            comment_lines: sub.comment_lines,
8509            blank_lines: sub.blank_lines,
8510            mixed_lines_separate: 0,
8511            functions: 0,
8512            classes: 0,
8513            variables: 0,
8514            imports: 0,
8515            test_count: 0,
8516            test_assertion_count: 0,
8517            test_suite_count: 0,
8518            coverage_lines_found: 0,
8519            coverage_lines_hit: 0,
8520            coverage_functions_found: 0,
8521            coverage_functions_hit: 0,
8522            coverage_branches_found: 0,
8523            coverage_branches_hit: 0,
8524        },
8525        totals_by_language: sub.language_summaries.clone(),
8526        per_file_records: sub_files,
8527        skipped_file_records: vec![],
8528        warnings: vec![],
8529        submodule_summaries: vec![],
8530        git_commit_short: parent.git_commit_short.clone(),
8531        git_commit_long: parent.git_commit_long.clone(),
8532        git_branch: parent.git_branch.clone(),
8533        git_commit_author: parent.git_commit_author.clone(),
8534        git_commit_date: parent.git_commit_date.clone(),
8535        git_tags: parent.git_tags.clone(),
8536        git_nearest_tag: parent.git_nearest_tag.clone(),
8537    }
8538}
8539
8540pub(crate) fn sanitize_project_label(raw: &str) -> String {
8541    let candidate = Path::new(raw)
8542        .file_name()
8543        .and_then(|name| name.to_str())
8544        .unwrap_or("project");
8545
8546    let mut value = String::with_capacity(candidate.len());
8547    for ch in candidate.chars() {
8548        if ch.is_ascii_alphanumeric() {
8549            value.push(ch.to_ascii_lowercase());
8550        } else {
8551            value.push('-');
8552        }
8553    }
8554
8555    let compact = value.trim_matches('-').to_string();
8556    if compact.is_empty() {
8557        "project".to_string()
8558    } else {
8559        compact
8560    }
8561}
8562
8563/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
8564/// comparisons with non-canonicalized stored paths work correctly.
8565fn strip_unc_prefix(path: PathBuf) -> PathBuf {
8566    let s = path.to_string_lossy();
8567    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8568        return PathBuf::from(format!(r"\\{rest}"));
8569    }
8570    if let Some(rest) = s.strip_prefix(r"\\?\") {
8571        return PathBuf::from(rest);
8572    }
8573    path
8574}
8575
8576fn display_path(path: &Path) -> String {
8577    let s = path.to_string_lossy();
8578    // Strip Windows extended-length prefix for display only; the underlying
8579    // PathBuf remains unchanged so file operations are unaffected.
8580    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
8581    // \\?\C:\path           →  C:\path          (local drive)
8582    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8583        return format!(r"\\{rest}");
8584    }
8585    if let Some(rest) = s.strip_prefix(r"\\?\") {
8586        return rest.to_owned();
8587    }
8588    s.into_owned()
8589}
8590
8591fn sanitize_path_str(s: &str) -> String {
8592    // Forward-slash variants of the Windows extended-length prefix that appear
8593    // when paths stored as plain strings have been processed through some path
8594    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
8595    if let Some(rest) = s.strip_prefix("//?/UNC/") {
8596        return format!("//{rest}");
8597    }
8598    if let Some(rest) = s.strip_prefix("//?/") {
8599        return rest.to_owned();
8600    }
8601    display_path(Path::new(s))
8602}
8603
8604fn workspace_root() -> PathBuf {
8605    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
8606    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
8607        let p = PathBuf::from(root);
8608        if p.is_dir() {
8609            return p;
8610        }
8611    }
8612
8613    // Current working directory — works for `cargo run` from the project root
8614    // and for scripts/run.sh which cds there first.
8615    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
8616}
8617
8618/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
8619fn make_git_label(repo: &str, ref_name: &str) -> String {
8620    if repo.is_empty() || ref_name.is_empty() {
8621        return String::new();
8622    }
8623    let base = repo
8624        .trim_end_matches('/')
8625        .trim_end_matches(".git")
8626        .rsplit('/')
8627        .next()
8628        .unwrap_or("repo");
8629    let ref_safe: String = ref_name
8630        .chars()
8631        .map(|c| {
8632            if c.is_alphanumeric() || c == '-' || c == '.' {
8633                c
8634            } else {
8635                '_'
8636            }
8637        })
8638        .collect();
8639    format!("{base}_at_{ref_safe}_sloc")
8640}
8641
8642/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
8643fn desktop_dir() -> PathBuf {
8644    if let Ok(profile) = std::env::var("USERPROFILE") {
8645        let p = PathBuf::from(profile).join("Desktop");
8646        if p.exists() {
8647            return p;
8648        }
8649    }
8650    if let Ok(home) = std::env::var("HOME") {
8651        let p = PathBuf::from(home).join("Desktop");
8652        if p.exists() {
8653            return p;
8654        }
8655    }
8656    workspace_root().join("out").join("web")
8657}
8658
8659fn resolve_input_path(raw: &str) -> PathBuf {
8660    let trimmed = raw.trim();
8661    if trimmed.is_empty() {
8662        return workspace_root().join("samples").join("basic");
8663    }
8664
8665    let candidate = PathBuf::from(trimmed);
8666    let resolved = if candidate.is_absolute() {
8667        candidate
8668    } else {
8669        let rooted = workspace_root().join(&candidate);
8670        if rooted.exists() {
8671            rooted
8672        } else {
8673            workspace_root().join(candidate)
8674        }
8675    };
8676
8677    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
8678    // strip that prefix so stored paths and the displayed "Project path" are clean.
8679    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
8680    PathBuf::from(display_path(&canonical))
8681}
8682
8683fn dir_size_bytes(path: &Path) -> u64 {
8684    let mut total = 0u64;
8685    if let Ok(rd) = fs::read_dir(path) {
8686        for entry in rd.filter_map(Result::ok) {
8687            let p = entry.path();
8688            if p.is_file() {
8689                if let Ok(meta) = p.metadata() {
8690                    total += meta.len();
8691                }
8692            } else if p.is_dir() {
8693                total += dir_size_bytes(&p);
8694            }
8695        }
8696    }
8697    total
8698}
8699
8700#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
8701fn format_dir_size(bytes: u64) -> String {
8702    if bytes >= 1_073_741_824 {
8703        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
8704    } else if bytes >= 1_048_576 {
8705        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
8706    } else if bytes >= 1_024 {
8707        format!("{:.0} KB", bytes as f64 / 1_024.0)
8708    } else {
8709        format!("{bytes} B")
8710    }
8711}
8712
8713#[allow(clippy::too_many_lines)]
8714fn build_preview_html(
8715    // NOSONAR(rust:S3776)
8716    root: &Path,
8717    include_patterns: &[String],
8718    exclude_patterns: &[String],
8719) -> Result<String> {
8720    if !root.exists() {
8721        return Ok(format!(
8722            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
8723            escape_html(&display_path(root))
8724        ));
8725    }
8726
8727    let _selected = display_path(root);
8728    let mut stats = PreviewStats::default();
8729    let mut rows = Vec::new();
8730    let mut languages = Vec::new();
8731    let mut budget = PreviewBudget {
8732        shown: 0,
8733        max_entries: 600,
8734        max_depth: 9,
8735    };
8736    let mut next_row_id = 1usize;
8737
8738    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
8739        || root.to_string_lossy().into_owned(),
8740        std::string::ToString::to_string,
8741    );
8742    let root_modified = root
8743        .metadata()
8744        .ok()
8745        .and_then(|meta| meta.modified().ok())
8746        .map_or_else(|| "-".to_string(), format_system_time);
8747
8748    rows.push(PreviewRow {
8749        row_id: 0,
8750        parent_row_id: None,
8751        depth: 0,
8752        name: format!("{root_name}/"),
8753        kind: PreviewKind::Dir,
8754        is_dir: true,
8755        language: None,
8756        modified: root_modified,
8757        type_label: "Directory".to_string(),
8758    });
8759    collect_preview_rows(
8760        root,
8761        root,
8762        0,
8763        Some(0),
8764        &mut next_row_id,
8765        &mut budget,
8766        &mut stats,
8767        &mut rows,
8768        &mut languages,
8769        include_patterns,
8770        exclude_patterns,
8771    )?;
8772
8773    let root_size = format_dir_size(dir_size_bytes(root));
8774
8775    let mut out = String::new();
8776    write!(
8777        out,
8778        r#"<div class="explorer-wrap" data-project-size="{}">"#,
8779        escape_html(&root_size)
8780    )
8781    .ok();
8782    out.push_str(r#"<div class="explorer-toolbar compact">"#);
8783    out.push_str(r#"<div class="explorer-title-group">"#);
8784    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
8785    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
8786    out.push_str(r"</div></div>");
8787
8788    out.push_str(r#"<div class="scope-stats">"#);
8789    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();
8790    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();
8791    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();
8792    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();
8793    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();
8794    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>"#);
8795    out.push_str(r"</div>");
8796
8797    let submodules = sloc_core::detect_submodules(root);
8798    if !submodules.is_empty() {
8799        let count = submodules.len();
8800        out.push_str(r#"<div class="submodule-preview-strip">"#);
8801        write!(
8802            out,
8803            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>"#,
8804            count,
8805            if count == 1 { "" } else { "s" }
8806        )
8807        .ok();
8808        out.push_str(r#"<div class="submodule-preview-chips">"#);
8809        for (sub_name, sub_rel_path) in &submodules {
8810            let sub_abs = root.join(sub_rel_path);
8811            let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
8812            let mut sub_stats = PreviewStats::default();
8813            let mut sub_rows: Vec<PreviewRow> = Vec::new();
8814            let mut sub_langs: Vec<&'static str> = Vec::new();
8815            let mut sub_budget = PreviewBudget {
8816                shown: 0,
8817                max_entries: 2000,
8818                max_depth: 9,
8819            };
8820            let mut sub_next_id = 1usize;
8821            let _ = collect_preview_rows(
8822                &sub_abs,
8823                &sub_abs,
8824                0,
8825                None,
8826                &mut sub_next_id,
8827                &mut sub_budget,
8828                &mut sub_stats,
8829                &mut sub_rows,
8830                &mut sub_langs,
8831                &[],
8832                &[],
8833            );
8834            let stats_json = format!(
8835                r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
8836                sub_stats.directories,
8837                sub_stats.files,
8838                sub_stats.supported,
8839                sub_stats.skipped,
8840                sub_stats.unsupported
8841            );
8842            write!(
8843                out,
8844                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>"#,
8845                escape_html(sub_name),
8846                escape_html(&sub_rel_path.to_string_lossy()),
8847                escape_html(&sub_size),
8848                escape_html(&stats_json),
8849                escape_html(sub_name),
8850                escape_html(&sub_size),
8851            )
8852            .ok();
8853        }
8854        out.push_str(r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#);
8855        out.push_str(r"</div>");
8856    }
8857
8858    out.push_str(r#"<div class="scope-info-row">"#);
8859    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
8860    if languages.is_empty() {
8861        out.push_str(
8862            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
8863        );
8864    } else {
8865        out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
8866        for language in &languages {
8867            if let Some(icon) = language_icon_file(language) {
8868                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();
8869            } else if let Some(svg) = language_inline_svg(language) {
8870                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();
8871            } else {
8872                write!(
8873                    out,
8874                    r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
8875                    escape_html(&language.to_ascii_lowercase()),
8876                    escape_html(language)
8877                )
8878                .ok();
8879            }
8880        }
8881    }
8882    out.push_str(r"</div></div>");
8883    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>"#);
8884    out.push_str(r"</div>");
8885
8886    out.push_str(r#"<div class="file-explorer-shell">"#);
8887    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>"#);
8888    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>"#);
8889    out.push_str(r#"<div class="file-explorer-tree">"#);
8890    for row in rows {
8891        let status_label = row.kind.label();
8892        let lang_attr = row.language.unwrap_or("");
8893        let toggle_html = if row.is_dir {
8894            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
8895                .to_string()
8896        } else {
8897            r#"<span class="tree-bullet">•</span>"#.to_string()
8898        };
8899        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();
8900    }
8901    if budget.shown >= budget.max_entries {
8902        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>"#);
8903    }
8904    out.push_str(r"</div></div></div>");
8905
8906    Ok(out)
8907}
8908
8909#[derive(Default)]
8910struct PreviewStats {
8911    directories: usize,
8912    files: usize,
8913    supported: usize,
8914    skipped: usize,
8915    unsupported: usize,
8916}
8917
8918struct PreviewRow {
8919    row_id: usize,
8920    parent_row_id: Option<usize>,
8921    depth: usize,
8922    name: String,
8923    kind: PreviewKind,
8924    is_dir: bool,
8925    language: Option<&'static str>,
8926    modified: String,
8927    type_label: String,
8928}
8929
8930#[derive(Copy, Clone)]
8931enum PreviewKind {
8932    Dir,
8933    Supported,
8934    Skipped,
8935    Unsupported,
8936}
8937
8938impl PreviewKind {
8939    const fn filter_key(self) -> &'static str {
8940        match self {
8941            Self::Dir => "dir",
8942            Self::Supported => "supported",
8943            Self::Skipped => "skipped",
8944            Self::Unsupported => "unsupported",
8945        }
8946    }
8947
8948    const fn label(self) -> &'static str {
8949        match self {
8950            Self::Dir => "dir",
8951            Self::Supported => "supported",
8952            Self::Skipped => "skipped by policy",
8953            Self::Unsupported => "unsupported",
8954        }
8955    }
8956
8957    const fn badge_class(self) -> &'static str {
8958        match self {
8959            Self::Dir => "badge badge-dir",
8960            Self::Supported => "badge badge-scan",
8961            Self::Skipped => "badge badge-skip",
8962            Self::Unsupported => "badge badge-unsupported",
8963        }
8964    }
8965
8966    const fn node_class(self) -> &'static str {
8967        match self {
8968            Self::Dir => "tree-node-dir",
8969            Self::Supported => "tree-node-supported",
8970            Self::Skipped => "tree-node-skipped",
8971            Self::Unsupported => "tree-node-unsupported",
8972        }
8973    }
8974}
8975
8976struct PreviewBudget {
8977    shown: usize,
8978    max_entries: usize,
8979    max_depth: usize,
8980}
8981
8982/// Handle a single directory entry inside `collect_preview_rows`.
8983/// Returns `true` when the entry was handled (caller should `continue`).
8984#[allow(clippy::too_many_arguments)]
8985fn handle_preview_dir_entry(
8986    root: &Path,
8987    path: &Path,
8988    name: &str,
8989    modified: String,
8990    depth: usize,
8991    parent_row_id: Option<usize>,
8992    row_id: usize,
8993    next_row_id: &mut usize,
8994    budget: &mut PreviewBudget,
8995    stats: &mut PreviewStats,
8996    rows: &mut Vec<PreviewRow>,
8997    languages: &mut Vec<&'static str>,
8998    include_patterns: &[String],
8999    exclude_patterns: &[String],
9000) -> Result<()> {
9001    let relative = preview_relative_path(root, path);
9002    if should_skip_preview_directory(&relative, exclude_patterns) {
9003        return Ok(());
9004    }
9005    stats.directories += 1;
9006    rows.push(PreviewRow {
9007        row_id,
9008        parent_row_id,
9009        depth: depth + 1,
9010        name: format!("{name}/"),
9011        kind: PreviewKind::Dir,
9012        is_dir: true,
9013        language: None,
9014        modified,
9015        type_label: "Directory".to_string(),
9016    });
9017    budget.shown += 1;
9018    if !matches!(name, ".git" | "node_modules" | "target") {
9019        collect_preview_rows(
9020            root,
9021            path,
9022            depth + 1,
9023            Some(row_id),
9024            next_row_id,
9025            budget,
9026            stats,
9027            rows,
9028            languages,
9029            include_patterns,
9030            exclude_patterns,
9031        )?;
9032    }
9033    Ok(())
9034}
9035
9036/// Handle a single file entry inside `collect_preview_rows`.
9037#[allow(clippy::too_many_arguments)]
9038fn handle_preview_file_entry(
9039    root: &Path,
9040    path: &Path,
9041    name: &str,
9042    modified: String,
9043    depth: usize,
9044    parent_row_id: Option<usize>,
9045    row_id: usize,
9046    budget: &mut PreviewBudget,
9047    stats: &mut PreviewStats,
9048    rows: &mut Vec<PreviewRow>,
9049    languages: &mut Vec<&'static str>,
9050    include_patterns: &[String],
9051    exclude_patterns: &[String],
9052) {
9053    let relative = preview_relative_path(root, path);
9054    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
9055        return;
9056    }
9057    stats.files += 1;
9058    let kind = classify_preview_file(name);
9059    match kind {
9060        PreviewKind::Supported => stats.supported += 1,
9061        PreviewKind::Skipped => stats.skipped += 1,
9062        PreviewKind::Unsupported => stats.unsupported += 1,
9063        PreviewKind::Dir => {}
9064    }
9065    let language = detect_language_name(name);
9066    if let Some(lang) = language {
9067        if !languages.contains(&lang) {
9068            languages.push(lang);
9069        }
9070    }
9071    rows.push(PreviewRow {
9072        row_id,
9073        parent_row_id,
9074        depth: depth + 1,
9075        name: name.to_owned(),
9076        kind,
9077        is_dir: false,
9078        language,
9079        modified,
9080        type_label: preview_type_label(name, language, kind),
9081    });
9082    budget.shown += 1;
9083}
9084
9085#[allow(clippy::too_many_arguments)]
9086#[allow(clippy::too_many_lines)]
9087fn collect_preview_rows(
9088    // NOSONAR(rust:S3776)
9089    root: &Path,
9090    dir: &Path,
9091    depth: usize,
9092    parent_row_id: Option<usize>,
9093    next_row_id: &mut usize,
9094    budget: &mut PreviewBudget,
9095    stats: &mut PreviewStats,
9096    rows: &mut Vec<PreviewRow>,
9097    languages: &mut Vec<&'static str>,
9098    include_patterns: &[String],
9099    exclude_patterns: &[String],
9100) -> Result<()> {
9101    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
9102        return Ok(());
9103    }
9104
9105    let mut entries = fs::read_dir(dir)
9106        .with_context(|| format!("failed to read directory {}", dir.display()))?
9107        .filter_map(std::result::Result::ok)
9108        .collect::<Vec<_>>();
9109    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
9110
9111    for entry in entries {
9112        if budget.shown >= budget.max_entries {
9113            break;
9114        }
9115
9116        let path = entry.path();
9117        let name = entry.file_name().to_string_lossy().into_owned();
9118        let Ok(metadata) = entry.metadata() else {
9119            continue;
9120        };
9121        let row_id = *next_row_id;
9122        *next_row_id += 1;
9123        let modified = metadata
9124            .modified()
9125            .ok()
9126            .map_or_else(|| "-".to_string(), format_system_time);
9127
9128        if metadata.is_dir() {
9129            handle_preview_dir_entry(
9130                root,
9131                &path,
9132                &name,
9133                modified,
9134                depth,
9135                parent_row_id,
9136                row_id,
9137                next_row_id,
9138                budget,
9139                stats,
9140                rows,
9141                languages,
9142                include_patterns,
9143                exclude_patterns,
9144            )?;
9145            continue;
9146        }
9147
9148        if metadata.is_file() {
9149            handle_preview_file_entry(
9150                root,
9151                &path,
9152                &name,
9153                modified,
9154                depth,
9155                parent_row_id,
9156                row_id,
9157                budget,
9158                stats,
9159                rows,
9160                languages,
9161                include_patterns,
9162                exclude_patterns,
9163            );
9164        }
9165    }
9166
9167    Ok(())
9168}
9169
9170fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
9171    if let Some(language) = language {
9172        return format!("{language} source");
9173    }
9174    let lower = name.to_ascii_lowercase();
9175    let ext = Path::new(&lower)
9176        .extension()
9177        .and_then(|e| e.to_str())
9178        .unwrap_or("");
9179    match kind {
9180        PreviewKind::Skipped => {
9181            if lower.ends_with(".min.js") {
9182                "Minified asset".to_string()
9183            } else if [
9184                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
9185            ]
9186            .contains(&ext)
9187            {
9188                "Binary or archive".to_string()
9189            } else {
9190                "Skipped file".to_string()
9191            }
9192        }
9193        PreviewKind::Unsupported => {
9194            if ext.is_empty() {
9195                "Unsupported file".to_string()
9196            } else {
9197                format!("{} file", ext.to_ascii_uppercase())
9198            }
9199        }
9200        PreviewKind::Supported => "Supported source".to_string(),
9201        PreviewKind::Dir => "Directory".to_string(),
9202    }
9203}
9204
9205fn format_system_time(time: SystemTime) -> String {
9206    #[allow(clippy::cast_possible_wrap)]
9207    let secs = match time.duration_since(UNIX_EPOCH) {
9208        Ok(duration) => duration.as_secs() as i64,
9209        Err(_) => return "-".to_string(),
9210    };
9211    let days = secs.div_euclid(86_400);
9212    let secs_of_day = secs.rem_euclid(86_400);
9213    let (year, month, day) = civil_from_days(days);
9214    let hour = secs_of_day / 3_600;
9215    let minute = (secs_of_day % 3_600) / 60;
9216    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
9217}
9218
9219#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9220fn civil_from_days(days: i64) -> (i32, u32, u32) {
9221    let z = days + 719_468;
9222    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
9223    let doe = z - era * 146_097;
9224    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
9225    let y = yoe + era * 400;
9226    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
9227    let mp = (5 * doy + 2) / 153;
9228    let d = doy - (153 * mp + 2) / 5 + 1;
9229    let m = mp + if mp < 10 { 3 } else { -9 };
9230    let year = y + i64::from(m <= 2);
9231    (year as i32, m as u32, d as u32)
9232}
9233
9234// The input is already lowercased via `to_ascii_lowercase()` before calling
9235// `ends_with`, so the comparisons are inherently case-insensitive.
9236#[allow(clippy::case_sensitive_file_extension_comparisons)]
9237fn detect_language_name(name: &str) -> Option<&'static str> {
9238    let lower = name.to_ascii_lowercase();
9239    if lower.ends_with(".c") || lower.ends_with(".h") {
9240        Some("C")
9241    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
9242        .iter()
9243        .any(|s| lower.ends_with(s))
9244    {
9245        Some("C++")
9246    } else if lower.ends_with(".cs") {
9247        Some("C#")
9248    } else if lower.ends_with(".py") {
9249        Some("Python")
9250    } else if lower.ends_with(".sh") {
9251        Some("Shell")
9252    } else if [".ps1", ".psm1", ".psd1"]
9253        .iter()
9254        .any(|s| lower.ends_with(s))
9255    {
9256        Some("PowerShell")
9257    } else {
9258        None
9259    }
9260}
9261
9262fn language_icon_file(language: &str) -> Option<&'static str> {
9263    match language {
9264        "C" => Some("c.png"),
9265        "C++" => Some("cpp.png"),
9266        "C#" => Some("c-sharp.png"),
9267        "Python" => Some("python.png"),
9268        "Shell" => Some("shell.png"),
9269        "PowerShell" => Some("powershell.png"),
9270        "JavaScript" => Some("java-script.png"),
9271        "HTML" => Some("html-5.png"),
9272        "Java" => Some("java.png"),
9273        "Visual Basic" => Some("visual-basic.png"),
9274        "Assembly" => Some("asm.png"),
9275        "Go" => Some("go.png"),
9276        "R" => Some("r.png"),
9277        "XML" => Some("xml.png"),
9278        "Groovy" => Some("groovy.png"),
9279        "Dockerfile" => Some("docker.png"),
9280        "Makefile" => Some("makefile.svg"),
9281        "Perl" => Some("perl.svg"),
9282        _ => None,
9283    }
9284}
9285
9286// Inline SVG badges for languages that have no PNG icon in images/icons/.
9287// Using inline SVG keeps the web UI fully self-contained — no extra files
9288// needed on disk, no 404s on air-gapped deployments.
9289// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
9290fn language_inline_svg(language: &str) -> Option<&'static str> {
9291    match language {
9292        "Rust" => Some(
9293            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>"##,
9294        ),
9295        "TypeScript" => Some(
9296            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>"##,
9297        ),
9298        _ => None,
9299    }
9300}
9301
9302// The input is already lowercased via `to_ascii_lowercase()` before the
9303// `ends_with` calls, so these comparisons are inherently case-insensitive.
9304#[allow(clippy::case_sensitive_file_extension_comparisons)]
9305fn classify_preview_file(name: &str) -> PreviewKind {
9306    let lower = name.to_ascii_lowercase();
9307
9308    let scannable = [
9309        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
9310        ".psm1", ".psd1",
9311    ]
9312    .iter()
9313    .any(|suffix| lower.ends_with(suffix));
9314
9315    if scannable {
9316        PreviewKind::Supported
9317    } else if lower.ends_with(".min.js")
9318        || lower.ends_with(".lock")
9319        || lower.ends_with(".png")
9320        || lower.ends_with(".jpg")
9321        || lower.ends_with(".jpeg")
9322        || lower.ends_with(".gif")
9323        || lower.ends_with(".zip")
9324        || lower.ends_with(".pdf")
9325        || lower.ends_with(".pyc")
9326        || lower.ends_with(".xz")
9327        || lower.ends_with(".tar")
9328        || lower.ends_with(".gz")
9329    {
9330        PreviewKind::Skipped
9331    } else {
9332        PreviewKind::Unsupported
9333    }
9334}
9335
9336fn preview_relative_path(root: &Path, path: &Path) -> String {
9337    path.strip_prefix(root)
9338        .ok()
9339        .unwrap_or(path)
9340        .to_string_lossy()
9341        .replace('\\', "/")
9342        .trim_matches('/')
9343        .to_string()
9344}
9345
9346fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
9347    if relative.is_empty() {
9348        return false;
9349    }
9350
9351    exclude_patterns.iter().any(|pattern| {
9352        wildcard_match(pattern, relative)
9353            || wildcard_match(pattern, &format!("{relative}/"))
9354            || wildcard_match(pattern, &format!("{relative}/placeholder"))
9355    })
9356}
9357
9358fn should_include_preview_file(
9359    relative: &str,
9360    include_patterns: &[String],
9361    exclude_patterns: &[String],
9362) -> bool {
9363    if relative.is_empty() {
9364        return true;
9365    }
9366
9367    let included = include_patterns.is_empty()
9368        || include_patterns
9369            .iter()
9370            .any(|pattern| wildcard_match(pattern, relative));
9371    let excluded = exclude_patterns
9372        .iter()
9373        .any(|pattern| wildcard_match(pattern, relative));
9374
9375    included && !excluded
9376}
9377
9378fn wildcard_match(pattern: &str, candidate: &str) -> bool {
9379    let pattern = pattern.trim().replace('\\', "/");
9380    let candidate = candidate.trim().replace('\\', "/");
9381    let p = pattern.as_bytes();
9382    let c = candidate.as_bytes();
9383    let mut pi = 0usize;
9384    let mut ci = 0usize;
9385    let mut star: Option<usize> = None;
9386    let mut star_match = 0usize;
9387
9388    while ci < c.len() {
9389        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
9390            pi += 1;
9391            ci += 1;
9392        } else if pi < p.len() && p[pi] == b'*' {
9393            while pi < p.len() && p[pi] == b'*' {
9394                pi += 1;
9395            }
9396            star = Some(pi);
9397            star_match = ci;
9398        } else if let Some(star_pi) = star {
9399            star_match += 1;
9400            ci = star_match;
9401            pi = star_pi;
9402        } else {
9403            return false;
9404        }
9405    }
9406
9407    while pi < p.len() && p[pi] == b'*' {
9408        pi += 1;
9409    }
9410
9411    pi == p.len()
9412}
9413
9414fn escape_html(value: &str) -> String {
9415    value
9416        .replace('&', "&amp;")
9417        .replace('<', "&lt;")
9418        .replace('>', "&gt;")
9419        .replace('"', "&quot;")
9420        .replace('\'', "&#39;")
9421}
9422
9423#[derive(Clone)]
9424struct SubmoduleRow {
9425    name: String,
9426    relative_path: String,
9427    files_analyzed: u64,
9428    code_lines: u64,
9429    comment_lines: u64,
9430    blank_lines: u64,
9431    total_physical_lines: u64,
9432    html_url: Option<String>,
9433}
9434
9435#[derive(Template)]
9436#[template(
9437    source = r##"
9438<!doctype html>
9439<html lang="en">
9440<head>
9441  <meta charset="utf-8">
9442  <title>OxideSLOC | tmp-sloc</title>
9443  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9444  <style nonce="{{ csp_nonce }}">
9445    :root {
9446      --bg: #efe9e2;
9447      --surface: #fcfaf7;
9448      --surface-2: #f7f0e8;
9449      --surface-3: #efe3d5;
9450      --line: #dfcfbf;
9451      --line-strong: #cfb29c;
9452      --text: #2f241c;
9453      --muted: #6f6257;
9454      --muted-2: #917f71;
9455      --nav: #b85d33;
9456      --nav-2: #7a371b;
9457      --accent: #2563eb;
9458      --accent-2: #1d4ed8;
9459      --oxide: #b85d33;
9460      --oxide-2: #8f4220;
9461      --success-bg: #eaf9ee;
9462      --success-text: #1c8746;
9463      --warn-bg: #fff2d8;
9464      --warn-text: #926000;
9465      --danger-bg: #fdeaea;
9466      --danger-text: #b33b3b;
9467      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
9468      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
9469      --radius: 14px;
9470    }
9471
9472    body.dark-theme {
9473      --bg: #1b1511;
9474      --surface: #261c17;
9475      --surface-2: #2d221d;
9476      --surface-3: #372922;
9477      --line: #524238;
9478      --line-strong: #6c5649;
9479      --text: #f5ece6;
9480      --muted: #c7b7aa;
9481      --muted-2: #aa9485;
9482      --nav: #b85d33;
9483      --nav-2: #7a371b;
9484      --accent: #6f9bff;
9485      --accent-2: #4a78ee;
9486      --oxide: #d37a4c;
9487      --oxide-2: #b35428;
9488      --success-bg: #163927;
9489      --success-text: #8fe2a8;
9490      --warn-bg: #3c2d11;
9491      --warn-text: #f3cb75;
9492      --danger-bg: #3d1f1f;
9493      --danger-text: #ff9f9f;
9494      --shadow: 0 14px 28px rgba(0,0,0,0.28);
9495      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
9496    }
9497
9498    * { box-sizing: border-box; }
9499    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); }
9500    html { overflow-y: scroll; }
9501    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
9502    .top-nav, .page, .loading { position: relative; z-index: 2; }
9503    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
9504    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
9505    .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); }
9506    .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; }
9507    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
9508    .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)); }
9509    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
9510    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
9511    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
9512    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
9513    .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; }
9514    .nav-project-pill.visible { display:inline-flex; }
9515    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
9516    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
9517    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
9518    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
9519    @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; } }
9520    .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; }
9521    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
9522    .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; }
9523    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
9524    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
9525    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
9526    .theme-toggle .icon-sun { display:none; }
9527    body.dark-theme .theme-toggle .icon-sun { display:block; }
9528    body.dark-theme .theme-toggle .icon-moon { display:none; }
9529    .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;}
9530    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
9531    .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);}
9532    .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;}
9533    .settings-close:hover{color:var(--text);background:var(--surface-2);}
9534    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
9535    .settings-modal-body{padding:14px 16px 16px;}
9536    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
9537    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
9538    .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;}
9539    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
9540    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
9541    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
9542    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
9543    .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;}
9544    .tz-select:focus{border-color:var(--oxide);}
9545    .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; }
9546    .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;}
9547    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
9548    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
9549    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
9550    .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; }
9551    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
9552    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
9553    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
9554    .wb-stats-header { padding: 10px 24px 0; }
9555    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
9556    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
9557    .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; }
9558    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9559    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9560    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9561    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
9562    .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; }
9563    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
9564    .ws-stat-analyzers { position: relative; }
9565    .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; }
9566    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
9567    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
9568    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
9569    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
9570    .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; }
9571    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
9572    .ws-divider { display: none; }
9573    .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%; }
9574    .ws-path-link:hover { color:var(--oxide); }
9575    body.dark-theme .ws-path-link { color:var(--oxide); }
9576    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
9577    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9578    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
9579    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9580    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
9581    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
9582    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
9583    .ws-mini-box-lg { flex:2 1 0; }
9584    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
9585    .ws-mini-box-br { flex:1.5 1 0; }
9586    .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); }
9587    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
9588    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
9589    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
9590    .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; }
9591    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
9592    .git-source-banner strong { font-weight:800; color:var(--text); }
9593    .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; }
9594    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
9595    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
9596    .git-source-banner a:hover { text-decoration:underline; }
9597    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
9598    .path-scope-sep { background:var(--line); margin:4px 14px; }
9599    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
9600    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
9601    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
9602    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
9603    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
9604    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
9605    .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; }
9606    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9607    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9608    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9609    .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; }
9610    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
9611    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
9612    [data-wb-tip] { cursor:help; }
9613    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
9614    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
9615    .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; }
9616    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
9617    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
9618    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
9619    .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; }
9620    .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); }
9621    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
9622    .side-info-card { padding: 18px; }
9623    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
9624    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
9625    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
9626    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
9627    .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); }
9628    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
9629    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9630    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
9631    .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; }
9632    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:start; min-height: calc(100vh - 57px); }
9633    .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; }
9634    .side-stack::-webkit-scrollbar { display: none; }
9635    .step-nav { padding: 20px 16px; }
9636    .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); }
9637    .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; }
9638    .step-button:hover { background: var(--surface-2); }
9639    .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); }
9640    .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; }
9641    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
9642    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
9643    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
9644    .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); }
9645    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
9646    .step-nav-sum-row:last-child { border-bottom:none; }
9647    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
9648    .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; }
9649    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
9650    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
9651    .quick-scan-section { padding: 10px 4px 14px; }
9652    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
9653    .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; }
9654    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
9655    .quick-scan-btn:active { transform:translateY(0); }
9656    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
9657    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
9658    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
9659    @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);} }
9660    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
9661    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
9662    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
9663    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
9664    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
9665    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
9666    .step-button.done .step-check { opacity:1; }
9667    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
9668    .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; }
9669    .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; }
9670    .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; }
9671    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
9672    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
9673    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
9674    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
9675    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
9676    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
9677    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
9678    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
9679    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
9680    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
9681    .card-body { padding: 22px; }
9682    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
9683    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
9684    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
9685    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
9686    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
9687    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
9688    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
9689    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
9690    .field { min-width:0; }
9691    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
9692    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; }
9693    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); }
9694    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
9695    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); }
9696    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
9697    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9698    .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; }
9699    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
9700    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
9701    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
9702    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
9703    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
9704    .input-group.compact { grid-template-columns: 1fr auto auto; }
9705    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
9706    .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)); }
9707    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
9708    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
9709    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
9710    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
9711    .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; }
9712    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
9713    .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; }
9714    .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); }
9715    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
9716    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
9717    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
9718    button.secondary { background: var(--surface); }
9719    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9720    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
9721    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
9722    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9723    .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); }
9724    .section + .wizard-actions { border-top: none; padding-top: 0; }
9725    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
9726    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9727    .field-help-grid.coupled-help { margin-top: 12px; }
9728    .field-help-grid.preset-grid { align-items: start; }
9729    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
9730    .preset-inline-row .field { margin: 0; }
9731    .preset-inline-row .explainer-card { margin: 0; }
9732    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
9733    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
9734    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
9735    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
9736    .preset-kv-row > :last-child { flex:1; min-width:0; }
9737    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
9738    .output-field-row .field { margin: 0; }
9739    .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; }
9740    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
9741    .step3-subtitle { margin-bottom: 10px; max-width: none; }
9742    .counting-intro { margin-bottom: 8px; max-width: none; }
9743    .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; }
9744    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
9745    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
9746    .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; }
9747    .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; }
9748    .section-spacer-top { margin-top: 28px; }
9749    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
9750    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
9751    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
9752    .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); }
9753    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
9754    .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; }
9755    .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; }
9756    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
9757    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9758    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
9759    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
9760    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
9761    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
9762    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
9763    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
9764    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
9765    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
9766    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
9767    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
9768    .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); }
9769    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
9770    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
9771    .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; }
9772    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
9773    .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; }
9774    .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; }
9775    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
9776    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
9777    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
9778    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
9779    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
9780    .advanced-rule-description strong { color: var(--text); }
9781    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
9782    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
9783    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
9784    .review-link:hover { text-decoration: underline; }
9785    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
9786    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
9787    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
9788    .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; }
9789    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
9790    .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
9791    .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
9792    body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
9793    .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
9794    body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
9795    .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; }
9796    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
9797    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
9798    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
9799    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9800    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
9801    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
9802    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
9803    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
9804    .review-card ul { padding-left: 18px; margin: 0; }
9805    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
9806    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
9807    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
9808    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
9809    .review-card { min-height: 200px; }
9810    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
9811    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
9812    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
9813    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
9814    .lang-overflow-chip { position:relative; cursor:default; }
9815    .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; }
9816    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
9817    .git-inline-row { align-items:start; }
9818    .mixed-line-card { display:flex; flex-direction:column; }
9819    .preset-inline-row .toggle-card { justify-content: center; }
9820        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
9821    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
9822    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
9823    .explorer-title { font-size: 18px; font-weight: 850; }
9824    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
9825    .explorer-subtitle.wide { max-width: none; }
9826    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
9827    .better-spacing { align-items:flex-start; justify-content:flex-end; }
9828    .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; }
9829    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
9830    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
9831    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
9832    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
9833    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
9834    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
9835    .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; }
9836    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
9837    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
9838    .scope-stat-button.supported { background: var(--success-bg); }
9839    .scope-stat-button.skipped { background: var(--warn-bg); }
9840    .scope-stat-button.unsupported { background: var(--danger-bg); }
9841    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
9842    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
9843    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
9844    [data-tooltip] { position: relative; }
9845    [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); }
9846    [data-tooltip]:hover::after { display: block; }
9847    .scope-stat-button[data-tooltip] { cursor: pointer; }
9848    .badge[data-tooltip] { cursor: help; }
9849    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
9850    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
9851    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
9852    .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; }
9853    .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; }
9854    code { display:inline-block; margin-top:0; padding:2px 7px; }
9855    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9856    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
9857    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
9858    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
9859    .language-pill.muted-pill { color: var(--muted); }
9860    button.language-pill { appearance:none; cursor:pointer; }
9861    .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); }
9862    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
9863    .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; }
9864    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
9865    .file-explorer-search-row { margin-left: auto; }
9866    .explorer-filter-select { min-width: 170px; width: 170px; }
9867    .explorer-search { min-width: 300px; width: 300px; }
9868    .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); }
9869    .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; }
9870    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
9871    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
9872    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
9873    .file-explorer-tree { max-height: 640px; overflow:auto; }
9874    .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); }
9875    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
9876    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
9877    .tree-row.hidden-by-filter { display:none !important; }
9878    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
9879    .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; }
9880    .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; }
9881    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
9882    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
9883    .tree-node { display:inline-flex; align-items:center; min-width:0; }
9884    .tree-node-dir { color: var(--text); font-weight: 800; }
9885    .tree-node-supported { color: var(--success-text); }
9886    .tree-node-skipped { color: var(--warn-text); }
9887    .tree-node-unsupported { color: var(--danger-text); }
9888    .tree-node-more { color: var(--muted-2); font-style: italic; }
9889    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
9890    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
9891    .tree-status-cell { display:flex; justify-content:flex-start; }
9892    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
9893    .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; }
9894    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
9895    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
9896    .cov-scan-idle { display:none; }
9897    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
9898    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
9899    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
9900    .cov-scan-title { font-weight:600; font-size:12.5px; }
9901    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
9902    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
9903    .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; }
9904    .cov-scan-use:hover { opacity:.75; }
9905    .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; }
9906    .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; }
9907    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
9908    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
9909    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
9910    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
9911    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
9912    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
9913    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
9914    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
9915    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
9916    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
9917    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
9918    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
9919    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
9920    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
9921    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
9922    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
9923    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
9924    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
9925    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
9926    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
9927    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
9928    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
9929    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
9930    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
9931    .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); }
9932    .loading.active { display:flex; }
9933    .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; }
9934    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
9935    .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; }
9936    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
9937    .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; }
9938    .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; }
9939    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
9940    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
9941    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
9942    .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; }
9943    .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
9944    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
9945    .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
9946    .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
9947    .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; }
9948    .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; }
9949    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
9950    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
9951    .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; }
9952    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
9953    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
9954    .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; }
9955    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
9956    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
9957    .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; }
9958    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
9959    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
9960    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
9961    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
9962    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
9963    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
9964    .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; }
9965    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
9966    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
9967    .hidden { display:none !important; }
9968    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
9969    .site-footer a{color:var(--muted);}
9970    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
9971    @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; } }
9972    .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;}
9973    @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));}}
9974    .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;}
9975    .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; }
9976    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
9977    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
9978    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
9979    .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; }
9980    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
9981    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
9982    .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; }
9983    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
9984    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
9985    .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; }
9986    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
9987    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
9988    .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; }
9989    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
9990    .info-icon-btn:hover { color:var(--text); }
9991    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); }
9992    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
9993    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
9994  </style>
9995</head>
9996<body>
9997  <div class="background-watermarks" aria-hidden="true">
9998    <img src="/images/logo/logo-text.png" alt="" />
9999    <img src="/images/logo/logo-text.png" alt="" />
10000    <img src="/images/logo/logo-text.png" alt="" />
10001    <img src="/images/logo/logo-text.png" alt="" />
10002    <img src="/images/logo/logo-text.png" alt="" />
10003    <img src="/images/logo/logo-text.png" alt="" />
10004    <img src="/images/logo/logo-text.png" alt="" />
10005    <img src="/images/logo/logo-text.png" alt="" />
10006    <img src="/images/logo/logo-text.png" alt="" />
10007    <img src="/images/logo/logo-text.png" alt="" />
10008    <img src="/images/logo/logo-text.png" alt="" />
10009    <img src="/images/logo/logo-text.png" alt="" />
10010    <img src="/images/logo/logo-text.png" alt="" />
10011    <img src="/images/logo/logo-text.png" alt="" />
10012  </div>
10013  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10014  <div class="top-nav">
10015    <div class="top-nav-inner">
10016      <a class="brand" href="/">
10017        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
10018        <div class="brand-copy">
10019          <div class="brand-title">OxideSLOC</div>
10020          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
10021        </div>
10022      </a>
10023      <div class="nav-project-slot">
10024        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
10025          <span class="nav-project-label">Project</span>
10026          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
10027        </div>
10028      </div>
10029      <div class="nav-status">
10030        <a class="nav-pill" href="/">Home</a>
10031        <div class="nav-dropdown">
10032          <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>
10033          <div class="nav-dropdown-menu">
10034            <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>
10035          </div>
10036        </div>
10037        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10038        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10039        <div class="nav-dropdown">
10040          <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>
10041          <div class="nav-dropdown-menu">
10042            <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>
10043          </div>
10044        </div>
10045        <div class="server-status-wrap">
10046          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
10047          <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>
10048        </div>
10049        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10050          <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>
10051        </button>
10052        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
10053          <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>
10054          <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>
10055        </button>
10056      </div>
10057    </div>
10058  </div>
10059
10060  <div class="loading" id="loading">
10061    <div class="loading-card">
10062      <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
10063      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
10064      <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
10065      <div class="lc-path" id="lc-path"></div>
10066      <div class="lc-metrics" id="lc-metrics">
10067        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
10068        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
10069      </div>
10070      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
10071      <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>
10072      <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>
10073      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
10074      <div class="lc-actions hidden" id="lc-actions">
10075        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
10076        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
10077      </div>
10078      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
10079        <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>
10080        Cancel scan
10081      </button>
10082    </div>
10083  </div>
10084
10085  <div class="page">
10086    <div class="workbench-strip">
10087      <div class="workbench-box wb-stats">
10088        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
10089          <span class="wb-stats-title">Analysis session</span>
10090        </div>
10091        <div class="ws-left">
10092          <div class="ws-stat ws-stat-analyzers">
10093            <span class="ws-label">Analyzers</span>
10094            <span class="ws-value">
10095              <span class="ws-badge">41 languages</span>
10096            </span>
10097            <div class="ws-lang-tooltip">
10098              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
10099              <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>
10100              <div class="ws-lang-grid">
10101                <span class="ws-lang-item">Assembly</span>
10102                <span class="ws-lang-item">C</span>
10103                <span class="ws-lang-item">C++</span>
10104                <span class="ws-lang-item">C#</span>
10105                <span class="ws-lang-item">Clojure</span>
10106                <span class="ws-lang-item">CSS</span>
10107                <span class="ws-lang-item">Dart</span>
10108                <span class="ws-lang-item">Dockerfile</span>
10109                <span class="ws-lang-item">Elixir</span>
10110                <span class="ws-lang-item">Erlang</span>
10111                <span class="ws-lang-item">F#</span>
10112                <span class="ws-lang-item">Go</span>
10113                <span class="ws-lang-item">Groovy</span>
10114                <span class="ws-lang-item">Haskell</span>
10115                <span class="ws-lang-item">HTML</span>
10116                <span class="ws-lang-item">Java</span>
10117                <span class="ws-lang-item">JavaScript</span>
10118                <span class="ws-lang-item">Julia</span>
10119                <span class="ws-lang-item">Kotlin</span>
10120                <span class="ws-lang-item">Lua</span>
10121                <span class="ws-lang-item">Makefile</span>
10122                <span class="ws-lang-item">Nim</span>
10123                <span class="ws-lang-item">Obj-C</span>
10124                <span class="ws-lang-item">OCaml</span>
10125                <span class="ws-lang-item">Perl</span>
10126                <span class="ws-lang-item">PHP</span>
10127                <span class="ws-lang-item">PowerShell</span>
10128                <span class="ws-lang-item">Python</span>
10129                <span class="ws-lang-item">R</span>
10130                <span class="ws-lang-item">Ruby</span>
10131                <span class="ws-lang-item">Rust</span>
10132                <span class="ws-lang-item">Scala</span>
10133                <span class="ws-lang-item">SCSS</span>
10134                <span class="ws-lang-item">Shell</span>
10135                <span class="ws-lang-item">SQL</span>
10136                <span class="ws-lang-item">Svelte</span>
10137                <span class="ws-lang-item">Swift</span>
10138                <span class="ws-lang-item">TypeScript</span>
10139                <span class="ws-lang-item">Vue</span>
10140                <span class="ws-lang-item">XML</span>
10141                <span class="ws-lang-item">Zig</span>
10142              </div>
10143            </div>
10144          </div>
10145          <div class="ws-divider"></div>
10146          <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>
10147          <div class="ws-divider"></div>
10148          <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>
10149          <div class="ws-divider"></div>
10150          <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.">
10151            <span class="ws-label">Output</span>
10152            <span class="ws-value">
10153              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
10154                <span id="ws-output-root">project/sloc</span>
10155              </button>
10156            </span>
10157          </div>
10158        </div>
10159      </div>
10160      <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.">
10161        <div class="ws-history-label">Scan history</div>
10162        <div class="ws-history-inner">
10163          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
10164            <div class="ws-mini-label">Scans</div>
10165            <div class="ws-mini-value" id="ws-scan-count">—</div>
10166          </div>
10167          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
10168            <div class="ws-mini-label">Last Scan</div>
10169            <div class="ws-mini-value" id="ws-last-scan">—</div>
10170          </div>
10171          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
10172            <div class="ws-mini-label">Branch</div>
10173            <div class="ws-mini-value" id="ws-branch">—</div>
10174          </div>
10175        </div>
10176      </div>
10177    </div>
10178
10179    <div class="layout">
10180      <aside class="side-stack">
10181        <section class="step-nav">
10182        <h3>Guided scan setup</h3>
10183        <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>
10184        <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>
10185        <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>
10186        <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>
10187
10188        <div class="step-steps-divider"></div>
10189
10190        <div class="step-nav-info" id="step-nav-info">
10191          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
10192          <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>
10193        </div>
10194
10195        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
10196          <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>
10197          <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>
10198          <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>
10199        </div>
10200
10201        <div class="quick-scan-divider"></div>
10202        <div class="quick-scan-section">
10203          <div class="quick-scan-label">No customization needed?</div>
10204          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
10205            <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>
10206            Quick Scan
10207          </button>
10208          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
10209        </div>
10210
10211        <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>
10212        </section>
10213
10214      </aside>
10215
10216      <section class="card">
10217        <div class="card-header">
10218          <div class="card-title-row">
10219            <div>
10220              <h1 class="card-title">Guided scan configuration</h1>
10221              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
10222            </div>
10223            <div class="wizard-progress" aria-label="Scan setup progress">
10224              <div class="wizard-progress-top">
10225                <span class="wizard-progress-label">Setup progress</span>
10226                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
10227              </div>
10228              <div class="wizard-progress-track">
10229                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
10230              </div>
10231            </div>
10232          </div>
10233        </div>
10234        <div class="card-body">
10235          <form method="post" action="/analyze" id="analyze-form">
10236            <div class="wizard-step active" data-step="1">
10237              <div class="section">
10238                <div class="section-kicker">Step 1</div>
10239                <h2>Select project and preview scope</h2>
10240                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
10241                <div class="field">
10242                  <label for="path">Project path</label>
10243                  {% if !git_repo.is_empty() %}
10244                  <div class="git-source-banner">
10245                    <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>
10246                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
10247                    <a href="/git-browser">← Back to Git Browser</a>
10248                  </div>
10249                  {% endif %}
10250                  <div class="path-scope-grid">
10251                      {% if !git_repo.is_empty() %}
10252                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
10253                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
10254                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
10255                      {% else %}
10256                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
10257                      <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
10258                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
10259                      {% endif %}
10260                    <div class="path-scope-sep"></div>
10261                    <div class="scope-legend-row">
10262                      <span class="scope-legend-label">Scope legend:</span>
10263                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
10264                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
10265                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
10266                    </div>
10267                  </div>
10268                  {% if git_repo.is_empty() %}
10269                  <div class="path-info-row">
10270                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
10271                      <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>
10272                      <span id="project-size-text">Project size: —</span>
10273                    </button>
10274                  </div>
10275                  {% else %}
10276                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
10277                  {% endif %}
10278                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
10279                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
10280                </div>
10281
10282                <div class="scope-preview-divider" aria-hidden="true"></div>
10283
10284                <div id="preview-panel">
10285                  <div class="preview-error">Loading preview...</div>
10286                </div>
10287              </div>
10288
10289              <div class="section" style="margin-top:14px;">
10290                <div class="preset-inline-row git-inline-row">
10291                  <div class="toggle-card" style="margin:0;">
10292                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
10293                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
10294                    <label class="checkbox">
10295                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
10296                      <div>
10297                        <span>Detect and separate git submodules</span>
10298                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
10299                      </div>
10300                    </label>
10301                  </div>
10302                  <div class="explainer-card prominent" style="margin:0;">
10303                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10304                    <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>
10305                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
10306    path = libs/core
10307    url  = https://github.com/org/core.git
10308
10309[submodule "libs/ui"]
10310    path = libs/ui
10311    url  = https://github.com/org/ui.git</div>
10312                  </div>
10313                </div>
10314              </div>
10315
10316              <div class="section">
10317                <div class="field-grid">
10318                  <div class="field">
10319                    <label for="include_globs">Include globs</label>
10320                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
10321                    <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>
10322                  </div>
10323                  <div class="field">
10324                    <label for="exclude_globs">Exclude globs</label>
10325                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
10326                    <div id="quick-exclude-chips" class="quick-excl-row">
10327                      <span class="quick-excl-label">Quick add:</span>
10328                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
10329                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
10330                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
10331                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
10332                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
10333                      <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>
10334                    </div>
10335                    <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>
10336                  </div>
10337                </div>
10338                <div class="glob-guidance-grid">
10339                  <div class="glob-guidance-card">
10340                    <strong>How to read them</strong>
10341                    <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>
10342                  </div>
10343                  <div class="glob-guidance-card">
10344                    <strong>Common include examples</strong>
10345                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
10346                  </div>
10347                  <div class="glob-guidance-card">
10348                    <strong>Common exclude examples</strong>
10349                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
10350                  </div>
10351                </div>
10352              </div>
10353
10354              <div class="section" style="margin-top:14px;">
10355                <div class="preset-inline-row git-inline-row">
10356                  <div class="toggle-card" style="margin:0;">
10357                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
10358                    <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>
10359                    <div class="field" style="margin:0;">
10360                      <div class="input-group compact">
10361                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
10362                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
10363                      </div>
10364                      <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>
10365                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
10366                    </div>
10367                  </div>
10368                  <div class="explainer-card prominent" style="margin:0;">
10369                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10370                    <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>
10371                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
10372lcov --capture --directory . --output-file coverage/lcov.info
10373
10374# C / C++ — llvm-cov (LCOV)
10375llvm-profdata merge -sparse default.profraw -o default.profdata
10376llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
10377
10378# C# — coverlet (Cobertura XML)
10379dotnet test --collect:"XPlat Code Coverage"
10380
10381# Python — pytest-cov (Cobertura XML)
10382pytest --cov --cov-report=xml
10383
10384# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
10385./gradlew jacocoTestReport</div>
10386                  </div>
10387                </div>
10388              </div>
10389
10390              <div class="wizard-actions">
10391                <div class="left"></div>
10392                <div class="right">
10393                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
10394                </div>
10395              </div>
10396            </div>
10397
10398            <div class="wizard-step" data-step="2">
10399              <div class="section">
10400                <div class="section-kicker">Step 2</div>
10401                <h2>Choose counting behavior</h2>
10402                <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>
10403                <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
10404                <div class="subsection-bar">Primary line classification</div>
10405                <div class="preset-kv-row">
10406                  <div class="toggle-card mixed-line-card" style="margin:0;">
10407                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
10408                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
10409                    <select id="mixed_line_policy" name="mixed_line_policy">
10410                      <option value="code_only">Code only</option>
10411                      <option value="code_and_comment">Code and comment</option>
10412                      <option value="comment_only">Comment only</option>
10413                      <option value="separate_mixed_category">Separate mixed category</option>
10414                    </select>
10415                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
10416                  </div>
10417                  <div class="explainer-card prominent" style="margin:0;">
10418                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
10419                    <div class="explainer-body" id="mixed-policy-description"></div>
10420                    <div class="code-sample" id="mixed-policy-example"></div>
10421                  </div>
10422                </div>
10423              </div>
10424
10425              <div class="subsection-bar">Additional scan rules</div>
10426              <div class="scan-rules-grid">
10427                <div class="preset-inline-row">
10428                  <div class="toggle-card" style="margin:0;">
10429                    <div class="field-help-title">Generated files</div>
10430                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
10431                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10432                  </div>
10433                  <div class="explainer-card prominent" style="margin:0;">
10434                    <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>
10435                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
10436# Files matching codegen patterns are excluded:
10437#   *.generated.cs  *.pb.go  *.g.dart</div>
10438                  </div>
10439                </div>
10440                <div class="preset-inline-row">
10441                  <div class="toggle-card" style="margin:0;">
10442                    <div class="field-help-title">Minified files</div>
10443                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
10444                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10445                  </div>
10446                  <div class="explainer-card prominent" style="margin:0;">
10447                    <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>
10448                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
10449# Heuristic: very long lines + low whitespace ratio
10450#   jquery.min.js  bundle.min.css  → skipped</div>
10451                  </div>
10452                </div>
10453                <div class="preset-inline-row">
10454                  <div class="toggle-card" style="margin:0;">
10455                    <div class="field-help-title">Vendor directories</div>
10456                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
10457                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10458                  </div>
10459                  <div class="explainer-card prominent" style="margin:0;">
10460                    <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>
10461                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
10462# Directories named vendor/ node_modules/ third_party/
10463#   → entire subtree is excluded from totals</div>
10464                  </div>
10465                </div>
10466                <div class="preset-inline-row">
10467                  <div class="toggle-card" style="margin:0;">
10468                    <div class="field-help-title">Lockfiles and manifests</div>
10469                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
10470                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
10471                  </div>
10472                  <div class="explainer-card prominent" style="margin:0;">
10473                    <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>
10474                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
10475# Files like package-lock.json  Cargo.lock  yarn.lock
10476#   → skipped unless this is enabled</div>
10477                  </div>
10478                </div>
10479                <div class="preset-inline-row">
10480                  <div class="toggle-card" style="margin:0;">
10481                    <div class="field-help-title">Binary handling</div>
10482                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
10483                    <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>
10484                  </div>
10485                  <div class="explainer-card prominent" style="margin:0;">
10486                    <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>
10487                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
10488# Detected via long lines + low whitespace heuristic
10489#   .png  .exe  .so  → skipped silently</div>
10490                  </div>
10491                </div>
10492                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
10493                  <div class="toggle-card" style="margin:0;">
10494                    <div class="field-help-title">Python docstrings</div>
10495                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
10496                    <label class="checkbox">
10497                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
10498                      <span>Count as comment-style lines</span>
10499                    </label>
10500                  </div>
10501                  <div class="explainer-card prominent" style="margin:0;">
10502                    <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>
10503                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
10504                  </div>
10505                </div>
10506              </div>
10507              <div class="always-tracked-tip">
10508                <div class="always-tracked-tip-icon">ℹ</div>
10509                <div class="always-tracked-tip-body">
10510                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
10511                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
10512                  <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>
10513                </div>
10514              </div>
10515
10516              <div class="wizard-actions">
10517                <div class="left">
10518                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
10519                </div>
10520                <div class="right">
10521                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
10522                </div>
10523              </div>
10524            </div>
10525
10526            <div class="wizard-step" data-step="3">
10527              <div class="section">
10528                <div class="section-kicker">Step 3</div>
10529                <h2>Output and report identity</h2>
10530                <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>
10531                <div class="preset-kv-row">
10532                  <div class="toggle-card" style="margin:0;">
10533                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
10534                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
10535                    <select id="scan_preset">
10536                      <option value="balanced">Balanced local scan</option>
10537                      <option value="code_focused">Code focused</option>
10538                      <option value="comment_audit">Comment audit</option>
10539                      <option value="deep_review">Deep review</option>
10540                    </select>
10541                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
10542                  </div>
10543                  <div class="explainer-card">
10544                    <div class="field-help-title">Selected scan preset</div>
10545                    <div class="explainer-body" id="scan-preset-description"></div>
10546                    <div class="preset-summary-row" id="scan-preset-summary"></div>
10547                    <div class="code-sample" id="scan-preset-example"></div>
10548                    <div class="preset-note" id="scan-preset-note"></div>
10549                  </div>
10550                </div>
10551                <hr class="step3-separator" />
10552                <div class="preset-kv-row">
10553                  <div class="toggle-card" style="margin:0;">
10554                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
10555                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
10556                    <select id="artifact_preset">
10557                      <option value="review">Review bundle</option>
10558                      <option value="full">Full bundle</option>
10559                      <option value="html_only">HTML only</option>
10560                      <option value="machine">Machine bundle</option>
10561                    </select>
10562                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
10563                  </div>
10564                  <div class="explainer-card">
10565                    <div class="field-help-title">Selected artifact preset</div>
10566                    <div class="explainer-body" id="artifact-preset-description"></div>
10567                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
10568                    <div class="code-sample" id="artifact-preset-example"></div>
10569                  </div>
10570                </div>
10571              </div>
10572
10573              <div class="section section-spacer-top">
10574                <div class="output-field-row">
10575                  <div class="field">
10576                    <label for="output_dir">Output directory</label>
10577                    <div class="input-group compact">
10578                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
10579                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
10580                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
10581                    </div>
10582                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
10583                  </div>
10584                  <div class="output-field-aside">
10585                    <strong>Where reports land</strong>
10586                    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.
10587                  </div>
10588                </div>
10589              </div>
10590
10591              <div class="section section-spacer-top">
10592                <div class="output-field-row">
10593                  <div class="field">
10594                    <label for="report_title">Report title</label>
10595                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
10596                    <div class="hint">Appears in HTML and PDF output headers.</div>
10597                  </div>
10598                  <div class="output-field-aside">
10599                    <strong>Shown in exported artifacts</strong>
10600                    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.
10601                  </div>
10602                </div>
10603              </div>
10604
10605              <div class="section section-spacer-top">
10606                <div class="output-field-row">
10607                  <div class="field">
10608                    <label for="report_header_footer">Report header / footer</label>
10609                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
10610                    <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>
10611                  </div>
10612                  <div class="output-field-aside">
10613                    <strong>Page-level identification</strong>
10614                    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.
10615                  </div>
10616                </div>
10617              </div>
10618
10619              <div class="section">
10620                <div class="section-kicker">Artifacts</div>
10621                <div class="artifact-grid" style="margin-bottom:24px;">
10622                  <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
10623                    <div class="marker">✓</div>
10624                    <div class="artifact-icon">H</div>
10625                    <h4>HTML report</h4>
10626                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
10627                    <div class="artifact-tags">
10628                      <span class="soft-chip">Best for visual review</span>
10629                      <span class="soft-chip">Embeddable preview</span>
10630                    </div>
10631                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
10632                  </div>
10633                  <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
10634                    <div class="marker">✓</div>
10635                    <div class="artifact-icon">P</div>
10636                    <h4>PDF export</h4>
10637                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
10638                    <div class="artifact-tags">
10639                      <span class="soft-chip">Portable snapshot</span>
10640                      <span class="soft-chip">Good for handoff</span>
10641                    </div>
10642                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
10643                  </div>
10644                  <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
10645                    <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>
10646                    <div class="marker">✓</div>
10647                    <div class="artifact-icon" style="color:var(--muted);">J</div>
10648                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
10649                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
10650                    <div class="artifact-tags">
10651                      <span class="soft-chip">Required for compare</span>
10652                      <span class="soft-chip">Auto-enabled</span>
10653                    </div>
10654                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
10655                  </div>
10656                </div>
10657                <div style="height:48px;flex-shrink:0;display:block;"></div>
10658                <div class="hint">HTML and PDF cards are selectable. Presets above can also toggle them for common workflows. JSON output is always generated.</div>
10659              </div>
10660
10661              <div class="wizard-actions">
10662                <div class="left">
10663                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
10664                </div>
10665                <div class="right">
10666                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
10667                </div>
10668              </div>
10669            </div>
10670
10671            <div class="wizard-step" data-step="4">
10672              <div class="section">
10673                <div class="section-kicker">Step 4</div>
10674                <h2>Review selections and run</h2>
10675                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
10676                <div class="review-grid">
10677                  <div class="review-card highlight">
10678                    <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>
10679                    <ul id="review-scan-summary"></ul>
10680                  </div>
10681                  <div class="review-card highlight">
10682                    <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>
10683                    <ul id="review-count-summary"></ul>
10684                  </div>
10685                  <div class="review-card">
10686                    <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>
10687                    <ul id="review-artifact-summary"></ul>
10688                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
10689                  </div>
10690                  <div class="review-card">
10691                    <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>
10692                    <ul id="review-preview-summary"></ul>
10693                  </div>
10694                </div>
10695              </div>
10696
10697              <div class="wizard-actions">
10698                <div class="left">
10699                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
10700                </div>
10701                <div class="right">
10702                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
10703                </div>
10704              </div>
10705            </div></form>
10706        </div>
10707      </section>
10708    </div>
10709  </div>
10710
10711  <script nonce="{{ csp_nonce }}">
10712    (function () {
10713      function startScanPhase() {
10714        var phaseEl = document.getElementById("scan-phase");
10715        if (!phaseEl) return;
10716        var phases = [
10717          "Discovering files...",
10718          "Decoding file encodings...",
10719          "Detecting languages...",
10720          "Analyzing source lines...",
10721          "Applying counting policies...",
10722          "Aggregating results...",
10723          "Rendering report..."
10724        ];
10725        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
10726        var i = 0;
10727        function next() {
10728          phaseEl.style.opacity = "0";
10729          setTimeout(function () {
10730            phaseEl.textContent = phases[i];
10731            phaseEl.style.opacity = "0.85";
10732            var delay = durations[i] || 1800;
10733            i++;
10734            if (i < phases.length) { setTimeout(next, delay); }
10735          }, 200);
10736        }
10737        next();
10738      }
10739
10740      var form = document.getElementById("analyze-form");
10741      var loading = document.getElementById("loading");
10742      var submitButton = document.getElementById("submit-button");
10743      var pathInput = document.getElementById("path");
10744      var GIT_MODE = !!(pathInput && pathInput.readOnly);
10745      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
10746      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
10747      var outputDirInput = document.getElementById("output_dir");
10748      var reportTitleInput = document.getElementById("report_title");
10749      var previewPanel = document.getElementById("preview-panel");
10750      var refreshButton = document.getElementById("refresh-preview");
10751      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
10752      var useSamplePath = document.getElementById("use-sample-path");
10753      var useDefaultOutput = document.getElementById("use-default-output");
10754      var browsePath = document.getElementById("browse-path");
10755      var browseOutputDir = document.getElementById("browse-output-dir");
10756      var browseCoverage = document.getElementById("browse-coverage");
10757      var coverageInput = document.getElementById("coverage_file");
10758      var covScanStatus = document.getElementById("cov-scan-status");
10759      var coverageSuggestTimer = null;
10760      var covAutoFilled = false;
10761      var themeToggle = document.getElementById("theme-toggle");
10762      var mixedLinePolicy = document.getElementById("mixed_line_policy");
10763      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
10764      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
10765      var scanPreset = document.getElementById("scan_preset");
10766      var artifactPreset = document.getElementById("artifact_preset");
10767      var includeGlobsInput = document.getElementById("include_globs");
10768      var excludeGlobsInput = document.getElementById("exclude_globs");
10769
10770      // Quick-exclude chips — append pattern to exclude_globs textarea.
10771      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
10772        chip.addEventListener("click", function() {
10773          var pattern = chip.getAttribute("data-pattern") || "";
10774          if (!pattern || !excludeGlobsInput) return;
10775          var current = excludeGlobsInput.value.trim();
10776          // For the "skip all" chip, replace any existing dep patterns cleanly.
10777          var patterns = pattern.split("\n");
10778          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
10779          var added = false;
10780          patterns.forEach(function(p) {
10781            p = p.trim();
10782            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
10783          });
10784          if (added) {
10785            excludeGlobsInput.value = lines.join("\n");
10786            excludeGlobsInput.dispatchEvent(new Event("input"));
10787          }
10788          chip.classList.add("active");
10789        });
10790      });
10791
10792      var liveReportTitle = document.getElementById("live-report-title");
10793      var navProjectPill = document.getElementById("nav-project-pill");
10794      var navProjectTitle = document.getElementById("nav-project-title");
10795      var reportTitlePreview = null;
10796      var wizardProgressFill = document.getElementById("wizard-progress-fill");
10797      var wizardProgressValue = document.getElementById("wizard-progress-value");
10798      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
10799      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
10800      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
10801      var reportTitleTouched = false;
10802      var currentStep = 1;
10803      var previewTimer = null;
10804      var quickScanBtn = document.getElementById("quick-scan-btn");
10805
10806      function dismissAnalysisModal() {
10807        if (loading) loading.classList.remove("active");
10808        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10809          var el = document.getElementById(id);
10810          if (el) el.classList.add("hidden");
10811        });
10812        var cancelBtn = document.getElementById("lc-cancel-btn");
10813        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
10814        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
10815        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
10816        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10817        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10818        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10819        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10820        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10821      }
10822
10823      var lcDismissBtn = document.getElementById("lc-dismiss");
10824      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
10825
10826      function startAsyncAnalysis(formData) {
10827        var gitRepo = (formData.get("git_repo") || "").toString();
10828        var gitRef  = (formData.get("git_ref")  || "").toString();
10829        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
10830        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
10831
10832        var pathEl = document.getElementById("lc-path");
10833        if (pathEl) pathEl.textContent = displayPath;
10834
10835        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10836          var el = document.getElementById(id);
10837          if (el) el.classList.add("hidden");
10838        });
10839        var cancelBtn = document.getElementById("lc-cancel-btn");
10840        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
10841        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10842        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10843        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10844        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
10845        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
10846
10847        if (loading) loading.classList.add("active");
10848
10849        var startTime = Date.now();
10850        var elapsedTimer = setInterval(function() {
10851          var s = Math.floor((Date.now() - startTime) / 1000);
10852          var el = document.getElementById("lc-elapsed");
10853          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
10854        }, 1000);
10855
10856        var warnShown = false, pollRetries = 0, activeWaitId = null;
10857
10858        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
10859
10860        function lcShowCancelled() {
10861          clearInterval(elapsedTimer);
10862          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
10863          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
10864          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
10865          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
10866          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
10867          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
10868          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
10869          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
10870          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10871          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10872        }
10873
10874        var lcCancelBtn = document.getElementById("lc-cancel-btn");
10875        if (lcCancelBtn) {
10876          lcCancelBtn.onclick = function() {
10877            if (!activeWaitId) { dismissAnalysisModal(); return; }
10878            lcCancelBtn.disabled = true;
10879            lcCancelBtn.textContent = "Cancelling…";
10880            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
10881              .then(function() { lcShowCancelled(); })
10882              .catch(function() { lcShowCancelled(); });
10883          };
10884        }
10885
10886        function lcShowError(msg) {
10887          clearInterval(elapsedTimer);
10888          lcSetPhase("Failed");
10889          var msgEl = document.getElementById("lc-err-msg");
10890          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
10891          var errEl = document.getElementById("lc-err");
10892          var actEl = document.getElementById("lc-actions");
10893          if (errEl) errEl.classList.remove("hidden");
10894          if (actEl) actEl.classList.remove("hidden");
10895          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10896          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10897        }
10898
10899        function lcPoll(waitId) {
10900          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
10901            .then(function(r) {
10902              if (!r.ok) throw new Error("HTTP " + r.status);
10903              return r.json();
10904            })
10905            .then(function(data) {
10906              pollRetries = 0;
10907              if (data.state === "complete") {
10908                clearInterval(elapsedTimer);
10909                lcSetPhase("Done");
10910                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
10911              } else if (data.state === "failed") {
10912                lcShowError(data.message);
10913              } else if (data.state === "cancelled") {
10914                lcShowCancelled();
10915              } else {
10916                var s = Math.floor((Date.now() - startTime) / 1000);
10917                if (s > 90 && !warnShown) {
10918                  warnShown = true;
10919                  var w = document.getElementById("lc-warn");
10920                  if (w) w.classList.remove("hidden");
10921                }
10922                lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
10923                setTimeout(function() { lcPoll(waitId); }, 1500);
10924              }
10925            })
10926            .catch(function() {
10927              pollRetries++;
10928              if (pollRetries >= 5) {
10929                lcShowError("Lost connection to server. Reload to check status.");
10930              } else {
10931                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
10932              }
10933            });
10934        }
10935
10936        var params = new URLSearchParams(formData);
10937        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
10938          .then(function(r) {
10939            var waitId = r.headers.get("x-wait-id");
10940            if (!waitId) { window.location.href = "/scan"; return; }
10941            activeWaitId = waitId;
10942            setTimeout(function() { lcPoll(waitId); }, 1500);
10943          })
10944          .catch(function(err) {
10945            lcShowError("Could not reach server: " + (err.message || err));
10946          });
10947      }
10948
10949      if (quickScanBtn) {
10950        quickScanBtn.addEventListener("click", function () {
10951          var pathVal = pathInput ? pathInput.value.trim() : "";
10952          if (!pathVal) {
10953            alert("Please enter or browse to a project path first.");
10954            return;
10955          }
10956          quickScanBtn.disabled = true;
10957          quickScanBtn.textContent = "Scanning...";
10958          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
10959          startAsyncAnalysis(new FormData(form));
10960        });
10961      }
10962
10963      var mixedPolicyInfo = {
10964        code_only: {
10965          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.",
10966          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'
10967        },
10968        code_and_comment: {
10969          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.",
10970          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'
10971        },
10972        comment_only: {
10973          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.",
10974          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'
10975        },
10976        separate_mixed_category: {
10977          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.",
10978          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'
10979        }
10980      };
10981
10982      var scanPresetInfo = {
10983        balanced: {
10984          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.",
10985          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
10986          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
10987          note: "Best when you want a stable local overview before making deeper adjustments.",
10988          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10989        },
10990        code_focused: {
10991          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
10992          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
10993          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
10994          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
10995          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10996        },
10997        comment_audit: {
10998          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
10999          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
11000          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
11001          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
11002          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
11003        },
11004        deep_review: {
11005          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
11006          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
11007          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
11008          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
11009          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
11010        }
11011      };
11012
11013      var artifactPresetInfo = {
11014        review: {
11015          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.",
11016          chips: ["HTML", "PDF"],
11017          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
11018        },
11019        full: {
11020          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.",
11021          chips: ["HTML", "PDF", "JSON"],
11022          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
11023        },
11024        html_only: {
11025          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.",
11026          chips: ["HTML only", "Fast local review"],
11027          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
11028        },
11029        machine: {
11030          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
11031          chips: ["HTML", "JSON"],
11032          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
11033        }
11034      };
11035
11036      function applyTheme(theme) {
11037        if (theme === "dark") document.body.classList.add("dark-theme");
11038        else document.body.classList.remove("dark-theme");
11039      }
11040
11041      function loadSavedTheme() {
11042        var saved = null;
11043        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
11044        applyTheme(saved === "dark" ? "dark" : "light");
11045      }
11046
11047      function updateScrollProgress() {
11048        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
11049        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
11050        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
11051        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
11052        var step = Math.min(Math.max(currentStep, 1), 4);
11053        var base = stepBase[step];
11054        var end  = stepEnd[step];
11055
11056        var scrollFrac = 0;
11057        var activePanel = document.querySelector(".wizard-step.active");
11058        if (activePanel) {
11059          var scrollTop = window.scrollY || window.pageYOffset || 0;
11060          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
11061          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
11062          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
11063          var scrolled = scrollTop + viewH - panelTop;
11064          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
11065        }
11066
11067        var percent = Math.round(base + (end - base) * scrollFrac);
11068        percent = Math.min(end, Math.max(base, percent));
11069        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
11070        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
11071      }
11072
11073      function updateWizardProgress() {
11074        updateScrollProgress();
11075      }
11076
11077      var stepDescriptions = [
11078        "Choose a project folder, apply scope filters, and preview which files will be counted.",
11079        "Configure how mixed code-plus-comment lines and docstrings are classified.",
11080        "Pick your output formats, scan preset, and where reports are saved.",
11081        "Review all settings and launch the analysis."
11082      ];
11083
11084      function updateStepNav(step) {
11085        var infoLabel = document.getElementById("step-nav-info-label");
11086        var infoDesc  = document.getElementById("step-nav-info-desc");
11087        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
11088        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
11089      }
11090
11091      function updateSidebarSummary() {
11092        var sumPath    = document.getElementById("sum-path");
11093        var sumPreset  = document.getElementById("sum-preset");
11094        var sumOutput  = document.getElementById("sum-output");
11095        var sidebarSummary = document.getElementById("sidebar-summary");
11096        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
11097        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
11098        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
11099        if (sumPath)   sumPath.textContent   = pathVal   || "—";
11100        if (sumPreset) sumPreset.textContent = presetVal || "—";
11101        if (sumOutput) sumOutput.textContent = outputVal || "—";
11102        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
11103      }
11104
11105      function setStep(step, pushHistory) {
11106        currentStep = step;
11107        stepPanels.forEach(function (panel) {
11108          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
11109        });
11110        stepButtons.forEach(function (button) {
11111          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
11112        });
11113        var layoutEl = document.querySelector(".layout");
11114        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
11115        updateWizardProgress();
11116        updateStepNav(step);
11117        stepButtons.forEach(function(btn) {
11118          var t = Number(btn.getAttribute("data-step-target"));
11119          btn.classList.toggle("done", t < step);
11120        });
11121        updateSidebarSummary();
11122
11123        if (pushHistory !== false) {
11124          try {
11125            history.pushState({ wizardStep: step }, "", "#step" + step);
11126          } catch (e) {}
11127        }
11128
11129        window.scrollTo({ top: 0, behavior: "instant" });
11130      }
11131
11132      window.addEventListener("popstate", function (e) {
11133        if (e.state && e.state.wizardStep) {
11134          setStep(e.state.wizardStep, false);
11135        } else {
11136          var hashMatch = location.hash.match(/^#step([1-4])$/);
11137          if (hashMatch) setStep(Number(hashMatch[1]), false);
11138        }
11139      });
11140
11141      function inferTitleFromPath(value) {
11142        if (!value) return "project";
11143        var cleaned = value.replace(/[\/\\]+$/, "");
11144        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
11145        return parts.length ? parts[parts.length - 1] : value;
11146      }
11147
11148      function updateReportTitleFromPath() {
11149        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
11150        if (!reportTitleTouched) {
11151          reportTitleInput.value = inferred;
11152        }
11153        var title = reportTitleInput.value || inferred;
11154        if (liveReportTitle) liveReportTitle.textContent = title;
11155        if (reportTitlePreview) reportTitlePreview.textContent = title;
11156        document.title = "OxideSLOC | " + title;
11157
11158        var projectPath = (pathInput.value || "").trim();
11159        if (navProjectPill && navProjectTitle) {
11160          if (projectPath.length > 0) {
11161            navProjectTitle.textContent = inferred;
11162            navProjectPill.classList.add("visible");
11163          } else {
11164            navProjectTitle.textContent = "";
11165            navProjectPill.classList.remove("visible");
11166          }
11167        }
11168      }
11169
11170      function updateMixedPolicyUI() {
11171        var key = mixedLinePolicy.value || "code_only";
11172        var info = mixedPolicyInfo[key];
11173        document.getElementById("mixed-policy-description").textContent = info.description;
11174        document.getElementById("mixed-policy-example").textContent = info.example;
11175      }
11176
11177      function updatePythonDocstringUI() {
11178        var checked = !!pythonDocstrings.checked;
11179        document.getElementById("python-docstring-example").textContent = checked
11180          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
11181          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
11182        document.getElementById("python-docstring-live-help").textContent = checked
11183          ? "Enabled: docstrings contribute to comment-style totals."
11184          : "Disabled: docstrings are not counted as comment content.";
11185      }
11186
11187      function renderPresetChips(targetId, chips) {
11188        var target = document.getElementById(targetId);
11189        if (!target) return;
11190        target.innerHTML = (chips || []).map(function (chip) {
11191          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
11192        }).join('');
11193      }
11194
11195      function updatePresetDescriptions() {
11196        var scanInfo = scanPresetInfo[scanPreset.value];
11197        var artifactInfo = artifactPresetInfo[artifactPreset.value];
11198        document.getElementById("scan-preset-description").textContent = scanInfo.description;
11199        document.getElementById("scan-preset-example").textContent = scanInfo.example;
11200        document.getElementById("scan-preset-note").textContent = scanInfo.note;
11201        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
11202        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
11203        renderPresetChips("scan-preset-summary", scanInfo.chips);
11204        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
11205      }
11206
11207      function applyScanPreset() {
11208        var info = scanPresetInfo[scanPreset.value];
11209        if (!info || !info.apply) return;
11210        mixedLinePolicy.value = info.apply.mixed;
11211        pythonDocstrings.checked = !!info.apply.docstrings;
11212        document.getElementById("generated_file_detection").value = info.apply.generated;
11213        document.getElementById("minified_file_detection").value = info.apply.minified;
11214        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
11215        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
11216        document.getElementById("binary_file_behavior").value = info.apply.binary;
11217        updateMixedPolicyUI();
11218        updatePythonDocstringUI();
11219      }
11220
11221      function applyArtifactPreset() {
11222        var enabled = { html: false, pdf: false };
11223        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
11224        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
11225        if (artifactPreset.value === "html_only") { enabled.html = true; }
11226        if (artifactPreset.value === "machine") { enabled.html = true; }
11227
11228        artifactCards.forEach(function (card) {
11229          var artifact = card.getAttribute("data-artifact");
11230          if (artifact === "json") return;
11231          var checked = !!enabled[artifact];
11232          var checkbox = card.querySelector(".artifact-checkbox");
11233          checkbox.checked = checked;
11234          card.classList.toggle("selected", checked);
11235        });
11236      }
11237
11238      function toggleArtifactCard(card) {
11239        var checkbox = card.querySelector(".artifact-checkbox");
11240        checkbox.checked = !checkbox.checked;
11241        card.classList.toggle("selected", checkbox.checked);
11242      }
11243
11244      function updateReview() {
11245        var scanSummary = document.getElementById("review-scan-summary");
11246        var countSummary = document.getElementById("review-count-summary");
11247        var artifactSummary = document.getElementById("review-artifact-summary");
11248        var outputSummary = document.getElementById("review-output-summary");
11249        var previewSummary = document.getElementById("review-preview-summary");
11250        var readinessSummary = document.getElementById("review-readiness-summary");
11251        var includeText = document.getElementById("include_globs").value.trim();
11252        var excludeText = document.getElementById("exclude_globs").value.trim();
11253        var sidePathPreview = document.getElementById("side-path-preview");
11254        var sideOutputPreview = document.getElementById("side-output-preview");
11255        var sideTitlePreview = document.getElementById("side-title-preview");
11256
11257        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
11258        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
11259        if (sideTitlePreview) {
11260          var rt = document.getElementById("report_title");
11261          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
11262        }
11263
11264        scanSummary.innerHTML = ""
11265          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
11266          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
11267          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
11268
11269        countSummary.innerHTML = ""
11270          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
11271          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
11272          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
11273          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
11274          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
11275          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
11276          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
11277          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
11278
11279        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
11280        artifactSummary.innerHTML = ""
11281          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
11282          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
11283
11284        outputSummary.innerHTML = ""
11285          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
11286          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
11287
11288        if (previewSummary) {
11289          if (GIT_MODE) {
11290            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>';
11291          } else {
11292          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
11293          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
11294          var statMap = {};
11295          statButtons.forEach(function (button) {
11296            var valueNode = button.querySelector('.scope-stat-value');
11297            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
11298          });
11299          previewSummary.innerHTML = ''
11300            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
11301            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
11302            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
11303            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
11304            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
11305            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
11306
11307          if (readinessSummary) {
11308            var selectedArtifactsCount = selectedArtifacts.length;
11309            readinessSummary.innerHTML = ''
11310              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
11311              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
11312              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
11313              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
11314          }
11315          } // end else (non-GIT_MODE)
11316        }
11317      }
11318
11319      function escapeHtml(value) {
11320        return String(value)
11321          .replace(/&/g, "&amp;")
11322          .replace(/</g, "&lt;")
11323          .replace(/>/g, "&gt;")
11324          .replace(/"/g, "&quot;")
11325          .replace(/'/g, "&#39;");
11326      }
11327
11328      function isPythonVisible() {
11329        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
11330      }
11331
11332      function syncPythonVisibility() {
11333        var html = previewPanel.textContent || "";
11334        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
11335        pythonWraps.forEach(function (node) {
11336          node.classList.toggle("hidden", !hasPython);
11337        });
11338      }
11339
11340      function attachPreviewInteractions() {
11341        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
11342        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
11343        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
11344        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
11345        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
11346        var searchInput = previewPanel.querySelector("#explorer-search");
11347        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
11348        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
11349        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
11350        var activeFilter = "all";
11351        var activeLanguage = "";
11352        var searchTerm = "";
11353        var currentSortKey = null;
11354        var currentSortOrder = "asc";
11355        var childRows = {};
11356
11357        rows.forEach(function (row) {
11358          var parentId = row.getAttribute("data-parent-id") || "";
11359          var rowId = row.getAttribute("data-row-id") || "";
11360          if (!childRows[parentId]) childRows[parentId] = [];
11361          childRows[parentId].push(rowId);
11362        });
11363
11364        function rowById(id) {
11365          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
11366        }
11367
11368        function hasCollapsedAncestor(row) {
11369          var parentId = row.getAttribute("data-parent-id");
11370          while (parentId) {
11371            var parent = rowById(parentId);
11372            if (!parent) break;
11373            if (parent.getAttribute("data-expanded") === "false") return true;
11374            parentId = parent.getAttribute("data-parent-id");
11375          }
11376          return false;
11377        }
11378
11379        function updateToggleGlyph(row) {
11380          var toggle = row.querySelector(".tree-toggle");
11381          if (!toggle) return;
11382          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
11383        }
11384
11385        function rowSortValue(row, key) {
11386          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
11387        }
11388
11389        function updateSortButtons() {
11390          sortButtons.forEach(function (button) {
11391            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
11392            var indicator = button.querySelector(".tree-sort-indicator");
11393            button.classList.toggle("active", isActive);
11394            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
11395            if (indicator) {
11396              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
11397            }
11398          });
11399        }
11400
11401        function sortSiblingRows() {
11402          if (!treeContainer) {
11403            updateSortButtons();
11404            return;
11405          }
11406
11407          var rowMap = {};
11408          var childrenMap = {};
11409          rows.forEach(function (row) {
11410            var rowId = row.getAttribute("data-row-id");
11411            var parentId = row.getAttribute("data-parent-id") || "";
11412            rowMap[rowId] = row;
11413            if (!childrenMap[parentId]) childrenMap[parentId] = [];
11414            childrenMap[parentId].push(rowId);
11415          });
11416
11417          Object.keys(childrenMap).forEach(function (parentId) {
11418            if (!parentId) return;
11419            childrenMap[parentId].sort(function (a, b) {
11420              var rowA = rowMap[a];
11421              var rowB = rowMap[b];
11422              if (!currentSortKey) {
11423                return Number(a) - Number(b);
11424              }
11425              var valueA = rowSortValue(rowA, currentSortKey);
11426              var valueB = rowSortValue(rowB, currentSortKey);
11427              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
11428              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
11429              var fallbackA = rowSortValue(rowA, "name");
11430              var fallbackB = rowSortValue(rowB, "name");
11431              if (fallbackA < fallbackB) return -1;
11432              if (fallbackA > fallbackB) return 1;
11433              return Number(a) - Number(b);
11434            });
11435          });
11436
11437          var orderedIds = [];
11438          function pushChildren(parentId) {
11439            (childrenMap[parentId] || []).forEach(function (childId) {
11440              orderedIds.push(childId);
11441              pushChildren(childId);
11442            });
11443          }
11444
11445          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
11446            orderedIds.push(topId);
11447            pushChildren(topId);
11448          });
11449
11450          orderedIds.forEach(function (id) {
11451            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
11452          });
11453          updateSortButtons();
11454        }
11455
11456        function updateLanguageButtons() {
11457          languageButtons.forEach(function (button) {
11458            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
11459            var isActive = languageValue === activeLanguage;
11460            button.classList.toggle("active", isActive);
11461          });
11462        }
11463
11464        function rowSelfMatches(row) {
11465          var kind = row.getAttribute("data-kind");
11466          var status = row.getAttribute("data-status");
11467          var language = (row.getAttribute("data-language") || "").toLowerCase();
11468          var name = row.getAttribute("data-name-lower") || "";
11469          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
11470          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
11471          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
11472          var passesLanguage = !activeLanguage || language === activeLanguage;
11473          return passesFilter && passesSearch && passesLanguage;
11474        }
11475
11476        function hasMatchingDescendant(rowId) {
11477          return (childRows[rowId] || []).some(function (childId) {
11478            var childRow = rowById(childId);
11479            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
11480          });
11481        }
11482
11483        function rowMatches(row) {
11484          if (rowSelfMatches(row)) return true;
11485          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
11486        }
11487
11488        function resetViewState() {
11489          activeFilter = "all";
11490          activeLanguage = "";
11491          searchTerm = "";
11492          currentSortKey = null;
11493          currentSortOrder = "asc";
11494          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11495          if (searchInput) searchInput.value = "";
11496          if (filterSelect) filterSelect.value = "all";
11497          updateLanguageButtons();
11498        }
11499
11500        function applyVisibility() {
11501          rows.forEach(function (row) {
11502            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
11503            row.classList.toggle("hidden-by-filter", !visible);
11504            row.style.display = visible ? "grid" : "none";
11505          });
11506          buttons.forEach(function (button) {
11507            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
11508          });
11509          if (filterSelect) filterSelect.value = activeFilter;
11510        }
11511
11512        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
11513        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
11514        var originalStats = {};
11515        buttons.forEach(function (btn) {
11516          var f = btn.getAttribute('data-filter');
11517          var v = btn.querySelector('.scope-stat-value');
11518          if (f && v) originalStats[f] = v.textContent;
11519        });
11520
11521        function applySubmoduleStats(statsJson) {
11522          try {
11523            var s = JSON.parse(statsJson);
11524            buttons.forEach(function (btn) {
11525              var f = btn.getAttribute('data-filter');
11526              var v = btn.querySelector('.scope-stat-value');
11527              if (!v) return;
11528              if (f === 'dir') v.textContent = s.dirs;
11529              else if (f === 'file') v.textContent = s.files;
11530              else if (f === 'supported') v.textContent = s.supported;
11531              else if (f === 'skipped') v.textContent = s.skipped;
11532              else if (f === 'unsupported') v.textContent = s.unsupported;
11533            });
11534          } catch (e) {}
11535        }
11536
11537        function restoreBaseRepoStats() {
11538          buttons.forEach(function (btn) {
11539            var f = btn.getAttribute('data-filter');
11540            var v = btn.querySelector('.scope-stat-value');
11541            if (v && originalStats[f]) v.textContent = originalStats[f];
11542          });
11543          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11544          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
11545        }
11546
11547        submoduleChips.forEach(function (chip) {
11548          chip.addEventListener('click', function () {
11549            var statsJson = chip.getAttribute('data-sub-stats');
11550            if (!statsJson) return;
11551            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11552            chip.classList.add('active');
11553            applySubmoduleStats(statsJson);
11554            if (baseRepoBtn) baseRepoBtn.style.display = '';
11555          });
11556        });
11557
11558        if (baseRepoBtn) {
11559          baseRepoBtn.addEventListener('click', function () {
11560            restoreBaseRepoStats();
11561            resetViewState();
11562            sortSiblingRows();
11563            applyVisibility();
11564          });
11565        }
11566
11567        buttons.forEach(function (button) {
11568          button.addEventListener("click", function () {
11569            var filterValue = button.getAttribute("data-filter") || "all";
11570            if (filterValue === "reset-view") {
11571              restoreBaseRepoStats();
11572              resetViewState();
11573              sortSiblingRows();
11574              applyVisibility();
11575              return;
11576            }
11577            activeFilter = filterValue;
11578            applyVisibility();
11579          });
11580        });
11581
11582        rows.forEach(function (row) {
11583          updateToggleGlyph(row);
11584          var toggle = row.querySelector(".tree-toggle");
11585          if (toggle) {
11586            toggle.addEventListener("click", function () {
11587              var expanded = row.getAttribute("data-expanded") !== "false";
11588              row.setAttribute("data-expanded", expanded ? "false" : "true");
11589              updateToggleGlyph(row);
11590              applyVisibility();
11591            });
11592          }
11593        });
11594
11595        actionButtons.forEach(function (button) {
11596          button.addEventListener("click", function () {
11597            var action = button.getAttribute("data-explorer-action");
11598            if (action === "expand-all") {
11599              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11600            } else if (action === "collapse-all") {
11601              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
11602            } else if (action === "clear-filters") {
11603              resetViewState();
11604            }
11605            sortSiblingRows();
11606            applyVisibility();
11607          });
11608        });
11609
11610        if (filterSelect) {
11611          filterSelect.addEventListener("change", function () {
11612            activeFilter = filterSelect.value || "all";
11613            applyVisibility();
11614          });
11615        }
11616
11617        languageButtons.forEach(function (button) {
11618          button.addEventListener("click", function () {
11619            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
11620            updateLanguageButtons();
11621            applyVisibility();
11622          });
11623        });
11624
11625        sortButtons.forEach(function (button) {
11626          button.addEventListener("click", function () {
11627            var sortKey = button.getAttribute("data-sort-key");
11628            if (currentSortKey === sortKey) {
11629              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
11630            } else {
11631              currentSortKey = sortKey;
11632              currentSortOrder = "asc";
11633            }
11634            sortSiblingRows();
11635            applyVisibility();
11636          });
11637        });
11638
11639        if (searchInput) {
11640          searchInput.addEventListener("input", function () {
11641            searchTerm = searchInput.value.trim().toLowerCase();
11642            applyVisibility();
11643          });
11644        }
11645
11646        updateLanguageButtons();
11647        sortSiblingRows();
11648        applyVisibility();
11649      }
11650
11651      function loadPreview() {
11652        if (!previewPanel || !pathInput) return;
11653        if (GIT_MODE) {
11654          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>';
11655          return;
11656        }
11657        var path = pathInput.value.trim();
11658        var zeroWarn = document.getElementById('zero-files-warning');
11659        if (!path) {
11660          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
11661          if (zeroWarn) zeroWarn.style.display = 'none';
11662          return;
11663        }
11664        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
11665        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
11666        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
11667        var previewUrl = "/preview?path=" + encodeURIComponent(path)
11668          + "&include_globs=" + encodeURIComponent(includeValue)
11669          + "&exclude_globs=" + encodeURIComponent(excludeValue);
11670        fetch(previewUrl)
11671          .then(function (response) { return response.text(); })
11672          .then(function (html) {
11673            previewPanel.innerHTML = html;
11674            attachPreviewInteractions();
11675            syncPythonVisibility();
11676            updateReview();
11677            setTimeout(collapseLanguagePills, 50);
11678            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
11679            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
11680            var sizeText = document.getElementById('project-size-text');
11681            var sizeBtn = document.getElementById('project-size-btn');
11682            if (sizeText && projectSize) {
11683              sizeText.textContent = 'Project size: ' + projectSize;
11684              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
11685            } else if (sizeText) {
11686              sizeText.textContent = 'Project size: —';
11687            }
11688            if (zeroWarn) {
11689              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
11690              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
11691              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
11692              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
11693              if (supportedCount === 0 && fileCount > 0) {
11694                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).';
11695                zeroWarn.style.display = '';
11696              } else {
11697                zeroWarn.style.display = 'none';
11698              }
11699            }
11700          })
11701          .catch(function (err) {
11702            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
11703          });
11704      }
11705
11706      function pickDirectory(targetInput, kind) {
11707        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
11708        if (browseButton) browseButton.disabled = true;
11709
11710        if (previewPanel && targetInput === pathInput) {
11711          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
11712        }
11713
11714        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
11715          .then(function (response) { return response.json(); })
11716          .then(function (data) {
11717            if (data && data.selected_path) {
11718              targetInput.value = data.selected_path;
11719
11720              if (targetInput === pathInput) {
11721                updateReportTitleFromPath();
11722                autoSetOutputDir(data.selected_path);
11723                fetchProjectHistory(data.selected_path);
11724                loadPreview();
11725                suggestCoverageFile(data.selected_path);
11726              }
11727
11728              updateReview();
11729            } else if (targetInput === pathInput) {
11730              // Cancelled — keep existing value and refresh preview with current path
11731              loadPreview();
11732            }
11733          })
11734          .catch(function () {
11735            window.alert("Directory picker request failed.");
11736            if (previewPanel && targetInput === pathInput) {
11737              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
11738            }
11739          })
11740          .finally(function () {
11741            if (browseButton) browseButton.disabled = false;
11742          });
11743      }
11744
11745      if (themeToggle) {
11746        themeToggle.addEventListener("click", function () {
11747          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
11748          applyTheme(nextTheme);
11749          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
11750        });
11751      }
11752
11753      stepButtons.forEach(function (button) {
11754        button.addEventListener("click", function () {
11755          setStep(Number(button.getAttribute("data-step-target")));
11756        });
11757      });
11758
11759      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
11760        button.addEventListener("click", function () {
11761          setStep(Number(button.getAttribute("data-step-target")) || 1);
11762        });
11763      });
11764
11765      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
11766        button.addEventListener("click", function () {
11767          updateReview();
11768          setStep(Number(button.getAttribute("data-next")));
11769        });
11770      });
11771
11772      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
11773        button.addEventListener("click", function () {
11774          setStep(Number(button.getAttribute("data-prev")));
11775        });
11776      });
11777
11778      document.addEventListener("keydown", function (e) {
11779        var tag = (document.activeElement || {}).tagName || "";
11780        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
11781        if (e.altKey || e.ctrlKey || e.metaKey) return;
11782        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
11783        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
11784      });
11785
11786      if (useSamplePath) {
11787        useSamplePath.addEventListener("click", function () {
11788          pathInput.value = "tests/fixtures/basic";
11789          updateReportTitleFromPath();
11790          autoSetOutputDir("tests/fixtures/basic");
11791          loadPreview();
11792          suggestCoverageFile("tests/fixtures/basic");
11793        });
11794      }
11795
11796      if (useDefaultOutput) {
11797        useDefaultOutput.addEventListener("click", function () {
11798          delete outputDirInput.dataset.userEdited;
11799          autoSetOutputDir(pathInput ? pathInput.value : "");
11800          updateReview();
11801        });
11802      }
11803
11804      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
11805      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
11806      if (browseCoverage) {
11807        browseCoverage.addEventListener("click", function () {
11808          browseCoverage.disabled = true;
11809          var currentVal = coverageInput ? coverageInput.value : "";
11810          fetch("/pick-directory?kind=coverage&current=" + encodeURIComponent(currentVal))
11811            .then(function (r) { return r.json(); })
11812            .then(function (d) {
11813              if (d && d.selected_path && coverageInput) {
11814                coverageInput.value = d.selected_path;
11815                setCovStatus("idle");
11816              }
11817            })
11818            .catch(function () {})
11819            .finally(function () { browseCoverage.disabled = false; });
11820        });
11821      }
11822
11823      function setCovStatus(state, opts) {
11824        if (!covScanStatus) return;
11825        opts = opts || {};
11826        covScanStatus.className = "cov-scan-status cov-scan-" + state;
11827        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
11828        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>';
11829        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>';
11830        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>';
11831        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>';
11832        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
11833        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
11834        if (state === "scanning") {
11835          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
11836        } else if (state === "found") {
11837          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
11838          html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
11839          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
11840          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
11841        } else if (state === "hint") {
11842          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
11843          html += '<div class="cov-scan-title">' + tb2 + ' detected &mdash; no coverage file found yet</div>';
11844          html += '<div class="cov-scan-sub">Generate one with:</div>';
11845          html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
11846        } else if (state === "none") {
11847          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
11848          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
11849        }
11850        html += '</div></div>';
11851        covScanStatus.innerHTML = html;
11852        if (state === "found") {
11853          var useBtn = covScanStatus.querySelector(".cov-scan-use");
11854          if (useBtn) useBtn.addEventListener("click", function () {
11855            if (coverageInput) coverageInput.value = "";
11856            covAutoFilled = false;
11857            setCovStatus("idle");
11858          });
11859        }
11860      }
11861
11862      function suggestCoverageFile(projectPath) {
11863        if (!coverageInput || !covScanStatus) return;
11864        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
11865        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
11866        clearTimeout(coverageSuggestTimer);
11867        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
11868        setCovStatus("scanning");
11869        coverageSuggestTimer = setTimeout(function () {
11870          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
11871            .then(function (r) { return r.json(); })
11872            .then(function (d) {
11873              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
11874              if (!d) { setCovStatus("none"); return; }
11875              if (d.found) {
11876                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
11877                setCovStatus("found", { found: d.found, tool: d.tool });
11878              } else if (d.tool && d.hint) {
11879                setCovStatus("hint", { tool: d.tool, hint: d.hint });
11880              } else {
11881                setCovStatus("none");
11882              }
11883            })
11884            .catch(function () { setCovStatus("idle"); });
11885        }, 600);
11886      }
11887
11888      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
11889
11890      if (coverageInput) coverageInput.addEventListener("input", function () {
11891        covAutoFilled = false;
11892        if (!this.value.trim()) setCovStatus("idle");
11893      });
11894
11895      // ── Language pill overflow: collapse to "+N more" chip ─────────────
11896      function collapseLanguagePills() {
11897        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
11898        rows.forEach(function(row) {
11899          // Remove any previous overflow chip
11900          var prev = row.querySelector('.lang-overflow-chip');
11901          if (prev) prev.remove();
11902          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
11903          pills.forEach(function(p) { p.style.display = ''; });
11904          if (!pills.length) return;
11905
11906          // Measure after restoring all pills
11907          var containerRight = row.getBoundingClientRect().right;
11908          var hidden = [];
11909          for (var i = pills.length - 1; i >= 1; i--) {
11910            var rect = pills[i].getBoundingClientRect();
11911            if (rect.right > containerRight + 2) {
11912              hidden.unshift(pills[i]);
11913              pills[i].style.display = 'none';
11914            } else {
11915              break;
11916            }
11917          }
11918
11919          if (hidden.length) {
11920            var chip = document.createElement('button');
11921            chip.type = 'button';
11922            chip.className = 'language-pill lang-overflow-chip';
11923            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
11924            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
11925            row.appendChild(chip);
11926          }
11927        });
11928      }
11929
11930      // Run after preview loads (preview panel populates language pills)
11931      var _origLoadPreviewCb = window.__previewLoaded;
11932      document.addEventListener('previewLoaded', collapseLanguagePills);
11933      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
11934      setTimeout(collapseLanguagePills, 400);
11935
11936      // ── Project history & output dir auto-set ──────────────────────────
11937      var wsOutputRoot   = document.getElementById("ws-output-root");
11938      var wsScanCount    = document.getElementById("ws-scan-count");
11939      var wsLastScan     = document.getElementById("ws-last-scan");
11940      var historyBadge   = document.getElementById("path-history-badge");
11941      var historyTimer   = null;
11942
11943      var wsOutputLink = document.getElementById("ws-output-link");
11944      function syncStripOutputRoot() {
11945        var val = outputDirInput ? outputDirInput.value : "";
11946        var display = val || "project/sloc";
11947        if (wsOutputRoot) wsOutputRoot.textContent = display;
11948        if (wsOutputLink) wsOutputLink.dataset.folder = val;
11949      }
11950
11951      function autoSetOutputDir(projectPath) {
11952        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
11953        if (GIT_MODE && GIT_OUTPUT_DIR) {
11954          outputDirInput.value = GIT_OUTPUT_DIR;
11955          syncStripOutputRoot();
11956          updateReview();
11957          return;
11958        }
11959        if (!projectPath || !projectPath.trim()) return;
11960        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
11961        outputDirInput.value = cleaned + "/sloc";
11962        syncStripOutputRoot();
11963        updateReview();
11964      }
11965
11966      var wsBranch = document.getElementById("ws-branch");
11967
11968      function fetchProjectHistory(projectPath) {
11969        if (!projectPath || !projectPath.trim()) {
11970          if (wsScanCount) wsScanCount.textContent = "—";
11971          if (wsLastScan)  wsLastScan.textContent  = "—";
11972          if (wsBranch)    wsBranch.textContent    = "—";
11973          if (historyBadge) historyBadge.style.display = "none";
11974          return;
11975        }
11976        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
11977          .then(function (r) { return r.ok ? r.json() : null; })
11978          .then(function (data) {
11979            if (!data) return;
11980            var countStr = data.scan_count > 0
11981              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
11982              : "never";
11983            var tsStr = data.last_scan_timestamp
11984              ? data.last_scan_timestamp.replace(" UTC","")
11985              : "—";
11986            if (wsScanCount) wsScanCount.textContent = countStr;
11987            if (wsLastScan)  wsLastScan.textContent  = tsStr;
11988            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
11989            if (data.scan_count > 0) {
11990              if (historyBadge) {
11991                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
11992                historyBadge.textContent = data.scan_count + " previous scan" +
11993                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
11994                  "Last: " + (data.last_scan_timestamp || "—") +
11995                  " — " + (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.";
11996                historyBadge.className = "path-history-badge found";
11997                historyBadge.style.display = "";
11998              }
11999            } else {
12000              if (historyBadge) historyBadge.style.display = "none";
12001            }
12002          })
12003          .catch(function () {});
12004      }
12005
12006      function onPathChange() {
12007        var val = pathInput ? pathInput.value : "";
12008        updateReportTitleFromPath();
12009        autoSetOutputDir(val);
12010        updateSidebarSummary();
12011        clearTimeout(historyTimer);
12012        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
12013        if (previewTimer) clearTimeout(previewTimer);
12014        previewTimer = setTimeout(loadPreview, 280);
12015        suggestCoverageFile(val);
12016      }
12017
12018      if (pathInput) {
12019        pathInput.addEventListener("input", onPathChange);
12020      }
12021
12022      if (outputDirInput) {
12023        outputDirInput.addEventListener("input", function () {
12024          outputDirInput.dataset.userEdited = "1";
12025          syncStripOutputRoot();
12026          updateReview();
12027        });
12028      }
12029
12030      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
12031        if (!node) return;
12032        node.addEventListener("input", function () {
12033          updateReview();
12034          if (previewTimer) clearTimeout(previewTimer);
12035          previewTimer = setTimeout(loadPreview, 280);
12036        });
12037      });
12038
12039      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
12040        var node = document.getElementById(id);
12041        if (node) node.addEventListener("change", updateReview);
12042      });
12043
12044      if (reportTitleInput) {
12045        reportTitleInput.addEventListener("input", function () {
12046          reportTitleTouched = reportTitleInput.value.trim().length > 0;
12047          updateReportTitleFromPath();
12048          updateReview();
12049        });
12050      }
12051
12052      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
12053      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
12054      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
12055      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
12056
12057      artifactCards.forEach(function (card) {
12058        card.addEventListener("click", function () {
12059          if (card.classList.contains("artifact-locked")) return;
12060          toggleArtifactCard(card);
12061          updateReview();
12062        });
12063      });
12064
12065      if (coverageInput) {
12066        coverageInput.addEventListener("input", function () {
12067          if (coverageInput.value.trim()) setCovStatus("idle");
12068        });
12069      }
12070
12071      if (form && loading && submitButton) {
12072        form.addEventListener("submit", function (e) {
12073          e.preventDefault();
12074          submitButton.disabled = true;
12075          submitButton.textContent = "Scanning...";
12076          startAsyncAnalysis(new FormData(form));
12077        });
12078      }
12079
12080      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
12081        btn.addEventListener('click', function () {
12082          var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
12083          if (!folder) return;
12084          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12085        });
12086      });
12087
12088      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
12089      if (wsOutputLink) {
12090        wsOutputLink.addEventListener('click', function () {
12091          var folder = wsOutputLink.dataset.folder || '';
12092          if (!folder) return;
12093          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12094        });
12095      }
12096
12097      loadSavedTheme();
12098      updateMixedPolicyUI();
12099      updatePythonDocstringUI();
12100      applyScanPreset();
12101      updatePresetDescriptions();
12102      applyArtifactPreset();
12103      updateReview();
12104      updateScrollProgress(); // initialise bar to 0% (step 1)
12105      window.addEventListener("scroll", updateScrollProgress, { passive: true });
12106      onPathChange();         // seed output dir, history badge, and preview from initial path
12107      loadPreview();
12108      updateStepNav(1);
12109
12110      // Restore step from URL hash on initial load (e.g., back-forward cache)
12111      (function() {
12112        var hashMatch = location.hash.match(/^#step([1-4])$/);
12113        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
12114      })();
12115
12116      (function randomizeWatermarks() {
12117        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
12118        if (!wms.length) return;
12119        var placed = [];
12120        function tooClose(top, left) {
12121          for (var i = 0; i < placed.length; i++) {
12122            var dt = Math.abs(placed[i][0] - top);
12123            var dl = Math.abs(placed[i][1] - left);
12124            if (dt < 16 && dl < 12) return true;
12125          }
12126          return false;
12127        }
12128        function pick(leftBand) {
12129          for (var attempt = 0; attempt < 50; attempt++) {
12130            var top = Math.random() * 88 + 2;
12131            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12132            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12133          }
12134          var top = Math.random() * 88 + 2;
12135          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12136          placed.push([top, left]);
12137          return [top, left];
12138        }
12139        var half = Math.floor(wms.length / 2);
12140        wms.forEach(function (img, i) {
12141          var pos = pick(i < half);
12142          var size = Math.floor(Math.random() * 80 + 110);
12143          var rot = (Math.random() * 360).toFixed(1);
12144          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
12145          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;
12146        });
12147      })();
12148
12149      (function spawnCodeParticles() {
12150        var container = document.getElementById('code-particles');
12151        if (!container) return;
12152        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'];
12153        for (var i = 0; i < 38; i++) {
12154          (function(idx) {
12155            var el = document.createElement('span');
12156            el.className = 'code-particle';
12157            el.textContent = snippets[idx % snippets.length];
12158            var left = Math.random() * 94 + 2;
12159            var top = Math.random() * 88 + 6;
12160            var dur = (Math.random() * 10 + 9).toFixed(1);
12161            var delay = (Math.random() * 18).toFixed(1);
12162            var rot = (Math.random() * 26 - 13).toFixed(1);
12163            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12164            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';
12165            container.appendChild(el);
12166          })(i);
12167        }
12168      })();
12169    })();
12170  </script>
12171  <script nonce="{{ csp_nonce }}">
12172    (function () {
12173      var raw = {{ prefill_json|safe }};
12174      if (!raw || typeof raw !== 'object' || !raw.path) return;
12175      function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12176      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
12177      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12178      setVal('path-input', raw.path || '');
12179      setVal('include-globs', raw.include_globs || '');
12180      setVal('exclude-globs', raw.exclude_globs || '');
12181      setVal('output-dir', raw.output_dir || '');
12182      setVal('report-title', raw.report_title || '');
12183      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
12184      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
12185      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
12186      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
12187      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
12188      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
12189      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
12190      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
12191      setChecked('generate-html', raw.generate_html !== false);
12192      setChecked('generate-pdf', !!raw.generate_pdf);
12193      // Trigger dynamic UI updates after pre-fill.
12194      setTimeout(function () {
12195        var pathEl = document.getElementById('path-input');
12196        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
12197        var policyEl = document.getElementById('mixed-line-policy');
12198        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
12199      }, 80);
12200    })();
12201  </script>
12202  <script nonce="{{ csp_nonce }}">
12203  (function(){
12204    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'}];
12205    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);});}
12206    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12207    function init(){
12208      var btn=document.getElementById('settings-btn');if(!btn)return;
12209      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12210      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>';
12211      document.body.appendChild(m);
12212      var g=document.getElementById('scheme-grid');
12213      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);});
12214      var cl=document.getElementById('settings-close');
12215      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);
12216      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');});
12217      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12218      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12219    }
12220    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12221  }());
12222  </script>
12223  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
12224    <div class="wb-ftip-arrow"></div>
12225    <span id="wb-ftip-text"></span>
12226  </div>
12227  <script nonce="{{ csp_nonce }}">(function(){
12228    var tip=document.getElementById('wb-ftip');
12229    var txt=document.getElementById('wb-ftip-text');
12230    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
12231    if(!tip||!txt)return;
12232    function pos(el){
12233      var r=el.getBoundingClientRect();
12234      tip.style.display='block';
12235      var tw=tip.offsetWidth;
12236      var lx=r.left+r.width/2-tw/2;
12237      if(lx<8)lx=8;
12238      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
12239      tip.style.left=lx+'px';
12240      tip.style.top=(r.bottom+8)+'px';
12241      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';}
12242    }
12243    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
12244      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
12245      el.addEventListener('mouseleave',function(){tip.style.display='none';});
12246    });
12247  })();
12248  (function(){
12249    function fixArtifactHintSpacing(){
12250      var grid=document.querySelector('.artifact-grid');
12251      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
12252    }
12253    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
12254  }());
12255  </script>
12256  <footer class="site-footer">
12257    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
12258    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12259    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12260    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12261    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12262  </footer>
12263</body>
12264</html>
12265"##,
12266    ext = "html"
12267)]
12268struct IndexTemplate {
12269    version: &'static str,
12270    prefill_json: String,
12271    csp_nonce: String,
12272    git_repo: String,
12273    git_ref: String,
12274    git_label_json: String,
12275    git_output_dir_json: String,
12276}
12277
12278// ── SplashTemplate ────────────────────────────────────────────────────────────
12279
12280#[derive(Template)]
12281#[template(
12282    source = r##"
12283<!doctype html>
12284<html lang="en">
12285<head>
12286  <meta charset="utf-8">
12287  <meta name="viewport" content="width=device-width, initial-scale=1">
12288  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
12289  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12290  <style nonce="{{ csp_nonce }}">
12291    :root {
12292      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
12293      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12294      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12295      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12296      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
12297    }
12298    body.dark-theme {
12299      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
12300      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
12301    }
12302    *{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);}
12303    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12304    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
12305    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12306    .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;}
12307    @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));}}
12308    .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);}
12309    .top-nav-inner{max-width:1400px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
12310    .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));}
12311    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
12312    .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;}
12313    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
12314    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12315    @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; } }
12316    .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;}
12317    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
12318    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
12319    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
12320    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
12321    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
12322    .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;}
12323    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12324    .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);}
12325    .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;}
12326    .settings-close:hover{color:var(--text);background:var(--surface-2);}
12327    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12328    .settings-modal-body{padding:14px 16px 16px;}
12329    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12330    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12331    .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;}
12332    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12333    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12334    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12335    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12336    .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;}
12337    .tz-select:focus{border-color:var(--oxide);}
12338    .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;}
12339    .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;}
12340    .page{max-width:1400px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
12341    .hero{text-align:center;margin:0 auto 18px;}
12342    .hero-logo-wrap{display:inline-block;cursor:default;}
12343    .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;}
12344    .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;}
12345    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
12346    .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;}
12347    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%);}
12348    .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;
12349      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
12350      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
12351      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;}
12352    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
12353    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
12354    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;}
12355    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:2.5em;opacity:0;}
12356    .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;}
12357    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
12358    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
12359    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
12360    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
12361    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
12362    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
12363    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
12364    .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;}
12365    .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;}
12366    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
12367    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12368    .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);}
12369    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
12370    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
12371    .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);}
12372    .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);}
12373    .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);}
12374    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
12375    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
12376    .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;}
12377    body.dark-theme .action-card-cta{color:var(--oxide);}
12378    .action-card.view .action-card-cta{color:var(--accent-2);}
12379    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
12380    .action-card.compare .action-card-cta{color:#7c3aed;}
12381    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
12382    .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);}
12383    .action-card.git-tools .action-card-cta{color:#15803d;}
12384    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
12385    .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);}
12386    .action-card.trend .action-card-cta{color:#0e7490;}
12387    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
12388    .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);}
12389    .action-card.automation .action-card-cta{color:#b45309;}
12390    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
12391    .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);}
12392    .action-card.test-metrics .action-card-cta{color:#be185d;}
12393    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
12394    .action-card:hover .action-card-cta{gap:12px;}
12395    .action-card.card-split{flex-direction:row;align-items:stretch;}
12396    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
12397    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
12398    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
12399    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
12400    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
12401    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
12402    .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;}
12403    .ac-badge.active{opacity:1;}
12404    .ac-badge.github{border-color:#555;color:#555;}
12405    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
12406    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
12407    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
12408    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
12409    body.dark-theme .ac-right-row{color:var(--muted);}
12410    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
12411    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
12412    .divider{height:1px;background:var(--line);margin:32px 0;}
12413    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
12414    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
12415    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
12416    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
12417      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
12418    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12419    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
12420    body.dark-theme .info-chip-val{color:var(--oxide);}
12421    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
12422    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
12423      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
12424      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
12425    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
12426      border:6px solid transparent;border-top-color:var(--text);}
12427    .info-chip:hover .info-chip-tip{display:block;}
12428    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
12429    .chip-slide.fading{filter:blur(5px);opacity:0;}
12430    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
12431    .site-footer a{color:var(--muted);}
12432    .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;}
12433    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
12434    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
12435    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
12436    .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;}
12437    .lan-badge.local{background:var(--oxide-2);}
12438    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
12439    .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);}
12440    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
12441    .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;}
12442    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
12443    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
12444    .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;}
12445    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
12446    .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;}
12447    .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);}
12448    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
12449    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
12450    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
12451    .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;}
12452  </style>
12453</head>
12454<body>
12455  <div class="background-watermarks" aria-hidden="true">
12456    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12457    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12458    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12459    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12460    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12461    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12462    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12463  </div>
12464  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12465  <div class="top-nav">
12466    <div class="top-nav-inner">
12467      <a class="brand" href="/">
12468        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12469        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
12470      </a>
12471      <div class="nav-right">
12472        <a class="nav-pill" href="/">Home</a>
12473        <div class="nav-dropdown">
12474          <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>
12475          <div class="nav-dropdown-menu">
12476            <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>
12477          </div>
12478        </div>
12479        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12480        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12481        <div class="nav-dropdown">
12482          <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>
12483          <div class="nav-dropdown-menu">
12484            <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>
12485          </div>
12486        </div>
12487        <div class="server-status-wrap">
12488          {% if server_mode %}
12489          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12490          <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>
12491          {% else %}
12492          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12493          <div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
12494          {% endif %}
12495        </div>
12496        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12497          <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>
12498        </button>
12499        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12500          <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>
12501          <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>
12502        </button>
12503      </div>
12504    </div>
12505  </div>
12506
12507  <div class="page">
12508    <div class="hero">
12509      <div class="hero-logo-wrap" id="hero-logo-wrap">
12510        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
12511      </div>
12512      <div class="hero-logo-shadow"></div>
12513      <div class="hero-title-wrap">
12514        <div class="hero-title-aura" aria-hidden="true"></div>
12515        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
12516      </div>
12517      <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>
12518    </div>
12519
12520    <div class="card-sections">
12521
12522      <div>
12523        <div class="card-section-label">Analysis</div>
12524        <div class="card-section-grid-2">
12525          <a class="action-card scan card-split" href="/scan-setup">
12526            <div class="action-card-left">
12527              <div class="action-card-icon">
12528                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
12529              </div>
12530              <div class="action-card-title">Scan Project</div>
12531              <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>
12532              <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>
12533            </div>
12534            <div class="action-card-sep"></div>
12535            <div class="action-card-right">
12536              <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>
12537              <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>
12538              <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>
12539              <div class="ac-right-stat" id="acp-scan-stat"></div>
12540            </div>
12541          </a>
12542          <a class="action-card test-metrics card-split" href="/test-metrics">
12543            <div class="action-card-left">
12544              <div class="action-card-icon">
12545                <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>
12546              </div>
12547              <div class="action-card-title">Test Metrics</div>
12548              <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>
12549              <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>
12550            </div>
12551            <div class="action-card-sep"></div>
12552            <div class="action-card-right">
12553              <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>
12554              <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>
12555              <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>
12556              <div class="ac-right-stat" id="acp-test-stat"></div>
12557            </div>
12558          </a>
12559        </div>
12560      </div>
12561
12562      <div>
12563        <div class="card-section-label">Reports &amp; Insights</div>
12564        <div class="card-section-grid-3">
12565          <a class="action-card view" href="/view-reports">
12566            <div class="action-card-icon">
12567              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
12568            </div>
12569            <div class="action-card-title">View Reports</div>
12570            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
12571            <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>
12572          </a>
12573          <a class="action-card compare" href="/compare-scans">
12574            <div class="action-card-icon">
12575              <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>
12576            </div>
12577            <div class="action-card-title">Compare Scans</div>
12578            <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>
12579            <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>
12580          </a>
12581          <a class="action-card trend" href="/trend-reports">
12582            <div class="action-card-icon">
12583              <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>
12584            </div>
12585            <div class="action-card-title">Trend Report</div>
12586            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
12587            <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>
12588          </a>
12589        </div>
12590      </div>
12591
12592      <div>
12593        <div class="card-section-label">Developer Tools</div>
12594        <div class="card-section-grid-2">
12595          <a class="action-card git-tools card-split" href="/git-browser">
12596            <div class="action-card-left">
12597              <div class="action-card-icon">
12598                <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>
12599              </div>
12600              <div class="action-card-title">Git Browser</div>
12601              <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>
12602              <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>
12603            </div>
12604            <div class="action-card-sep"></div>
12605            <div class="action-card-right">
12606              <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>
12607              <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>
12608              <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>
12609            </div>
12610          </a>
12611          <a class="action-card automation card-split" href="/integrations">
12612            <div class="action-card-left">
12613              <div class="action-card-icon">
12614                <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>
12615              </div>
12616              <div class="action-card-title">Integrations</div>
12617              <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>
12618              <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>
12619            </div>
12620            <div class="action-card-sep"></div>
12621            <div class="action-card-right">
12622              <div class="ac-badges-grid">
12623                <span class="ac-badge github"     id="acp-gh">GitHub</span>
12624                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
12625                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
12626                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
12627              </div>
12628              <div class="ac-right-stat" id="acp-int-stat"></div>
12629            </div>
12630          </a>
12631        </div>
12632      </div>
12633
12634    </div>
12635
12636    {% if server_mode %}
12637    <div class="lan-card server">
12638      <div class="lan-card-header">
12639        <span class="lan-badge">LAN server</span>
12640        Accessible on your network
12641      </div>
12642      {% if let Some(ip) = lan_ip %}
12643      <div class="lan-url-row">
12644        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
12645        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
12646          <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>
12647          Copy URL
12648        </button>
12649      </div>
12650      <p class="lan-hint">Share this address with anyone on the same network. They will be asked to authenticate.</p>
12651      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
12652      {% else %}
12653      <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>
12654      {% endif %}
12655    </div>
12656    {% endif %}
12657
12658    <div class="divider"></div>
12659
12660    <div class="info-strip">
12661      <div class="info-chip">
12662        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
12663        <div class="chip-slide">
12664          <div class="info-chip-val">41</div>
12665          <div class="info-chip-label">Languages</div>
12666        </div>
12667      </div>
12668      <div class="info-chip">
12669        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
12670        <div class="chip-slide">
12671          <div class="info-chip-val">100%</div>
12672          <div class="info-chip-label">Self-contained</div>
12673        </div>
12674      </div>
12675      <div class="info-chip">
12676        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
12677        <div class="chip-slide">
12678          <div class="info-chip-val">HTML+PDF</div>
12679          <div class="info-chip-label">Exportable reports</div>
12680        </div>
12681      </div>
12682      <div class="info-chip">
12683        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
12684        <div class="chip-slide">
12685          <div class="info-chip-val">Webhook</div>
12686          <div class="info-chip-label">3 platforms</div>
12687        </div>
12688      </div>
12689      <div class="info-chip">
12690        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
12691        <div class="chip-slide">
12692          <div class="info-chip-val">IEEE</div>
12693          <div class="info-chip-label">1045-1992</div>
12694        </div>
12695      </div>
12696    </div>
12697
12698    {% if lan_ip.is_none() %}
12699    <div class="lan-local-hint">
12700      <strong>Want teammates on the same network to access this?</strong><br>
12701      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
12702    </div>
12703    {% endif %}
12704  </div>
12705
12706  <footer class="site-footer">
12707    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
12708    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12709    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12710    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12711    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12712  </footer>
12713
12714  <script nonce="{{ csp_nonce }}">
12715    (function () {
12716      var storageKey = 'oxide-sloc-theme';
12717      var body = document.body;
12718      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
12719      var toggle = document.getElementById('theme-toggle');
12720      if (toggle) toggle.addEventListener('click', function () {
12721        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
12722        body.classList.toggle('dark-theme', next === 'dark');
12723        try { localStorage.setItem(storageKey, next); } catch(e) {}
12724      });
12725      var copyBtn = document.getElementById('lan-copy-btn');
12726      if (copyBtn) copyBtn.addEventListener('click', function() {
12727        var btn = this;
12728        var el = document.getElementById('lan-url-val');
12729        if (!el) return;
12730        var url = el.textContent.trim();
12731        if (navigator.clipboard) {
12732          navigator.clipboard.writeText(url).then(function() {
12733            var orig = btn.innerHTML;
12734            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!';
12735            setTimeout(function() { btn.innerHTML = orig; }, 1800);
12736          });
12737        }
12738      });
12739      (function randomizeWatermarks() {
12740        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12741        if (!wms.length) return;
12742        var placed = [];
12743        function tooClose(top, left) {
12744          for (var i = 0; i < placed.length; i++) {
12745            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12746            if (dt < 16 && dl < 12) return true;
12747          }
12748          return false;
12749        }
12750        function pick(leftBand) {
12751          for (var attempt = 0; attempt < 50; attempt++) {
12752            var top = Math.random() * 88 + 2;
12753            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12754            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12755          }
12756          var top = Math.random() * 88 + 2;
12757          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12758          placed.push([top, left]); return [top, left];
12759        }
12760        var half = Math.floor(wms.length / 2);
12761        wms.forEach(function (img, i) {
12762          var pos = pick(i < half);
12763          var size = Math.floor(Math.random() * 100 + 120);
12764          var rot = (Math.random() * 360).toFixed(1);
12765          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12766          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;
12767        });
12768      })();
12769
12770      (function spawnCodeParticles() {
12771        var container = document.getElementById('code-particles');
12772        if (!container) return;
12773        var snippets = [
12774          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12775          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12776          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12777          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12778          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
12779        ];
12780        var count = 38;
12781        for (var i = 0; i < count; i++) {
12782          (function(idx) {
12783            var el = document.createElement('span');
12784            el.className = 'code-particle';
12785            var text = snippets[idx % snippets.length];
12786            el.textContent = text;
12787            var left = Math.random() * 94 + 2;
12788            var top = Math.random() * 88 + 6;
12789            var dur = (Math.random() * 10 + 9).toFixed(1);
12790            var delay = (Math.random() * 18).toFixed(1);
12791            var rot = (Math.random() * 26 - 13).toFixed(1);
12792            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12793            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
12794              + '--rot:' + rot + 'deg;--op:' + op + ';'
12795              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
12796            container.appendChild(el);
12797          })(i);
12798        }
12799      })();
12800      (function heroAnimations() {
12801        var sub = document.getElementById('hero-subtitle');
12802        if (sub) {
12803          var full = sub.textContent.trim();
12804          sub.textContent = '';
12805          sub.style.opacity = '1';
12806          var cursor = document.createElement('span');
12807          cursor.className = 'hero-cursor';
12808          sub.appendChild(cursor);
12809          var i = 0;
12810          setTimeout(function() {
12811            var iv = setInterval(function() {
12812              if (i < full.length) {
12813                sub.insertBefore(document.createTextNode(full[i]), cursor);
12814                i++;
12815              } else {
12816                clearInterval(iv);
12817                setTimeout(function() {
12818                  cursor.style.transition = 'opacity 1s ease';
12819                  cursor.style.opacity = '0';
12820                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
12821                }, 2400);
12822              }
12823            }, 11);
12824          }, 374);
12825        }
12826      })();
12827      (function logoBob() {
12828        var logo = document.querySelector('.hero-logo');
12829        var shadow = document.querySelector('.hero-logo-shadow');
12830        if (!logo) return;
12831        var cycleStart = null, cycleDur = 3600;
12832        var peakY = -14, peakScale = 1.07, peakRot = 0;
12833        function newCycle() {
12834          cycleDur = 3000 + Math.random() * 1840;
12835          peakY = -(9 + Math.random() * 13.8);
12836          peakScale = 1.04 + Math.random() * 0.081;
12837          peakRot = (Math.random() * 11.5 - 5.75);
12838        }
12839        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
12840        newCycle();
12841        function frame(ts) {
12842          if (cycleStart === null) cycleStart = ts;
12843          var t = (ts - cycleStart) / cycleDur;
12844          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
12845          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
12846          var y = peakY * phase;
12847          var sc = 1 + (peakScale - 1) * phase;
12848          var rot = peakRot * Math.sin(Math.PI * phase);
12849          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
12850          if (shadow) {
12851            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
12852            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
12853          }
12854          requestAnimationFrame(frame);
12855        }
12856        requestAnimationFrame(frame);
12857      })();
12858      (function mouseEffects() {
12859        var heroTitle = document.getElementById('hero-title');
12860        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
12861        function tick() {
12862          raf = null;
12863          if (heroTitle) {
12864            var r = heroTitle.getBoundingClientRect();
12865            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
12866            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
12867            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
12868          }
12869        }
12870        document.addEventListener('mousemove', function(e) {
12871          mx = e.clientX; my = e.clientY;
12872          if (!raf) raf = requestAnimationFrame(tick);
12873        });
12874        document.addEventListener('mouseleave', function() {
12875          if (heroTitle) {
12876            heroTitle.style.transition = 'transform 0.5s ease';
12877            heroTitle.style.transform = '';
12878            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
12879          }
12880        });
12881        document.querySelectorAll('.action-card').forEach(function(card) {
12882          card.addEventListener('mousemove', function(e) {
12883            var rect = card.getBoundingClientRect();
12884            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
12885            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
12886            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
12887            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
12888          });
12889          card.addEventListener('mouseleave', function() {
12890            card.style.transition = '';
12891            card.style.transform = '';
12892          });
12893        });
12894      })();
12895      (function chipSlideshow() {
12896        var slides = [
12897          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
12898          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
12899          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
12900          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
12901          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
12902        ];
12903        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
12904        var indices = [0,0,0,0,0];
12905        var paused = [false,false,false,false,false];
12906        chips.forEach(function(chip, i) {
12907          chip.addEventListener('mouseenter', function() { paused[i] = true; });
12908          chip.addEventListener('mouseleave', function() { paused[i] = false; });
12909        });
12910        function advance(i) {
12911          if (paused[i]) return;
12912          var chip = chips[i];
12913          var inner = chip.querySelector('.chip-slide');
12914          if (!inner) return;
12915          inner.classList.add('fading');
12916          setTimeout(function() {
12917            indices[i] = (indices[i] + 1) % slides[i].length;
12918            var s = slides[i][indices[i]];
12919            chip.querySelector('.info-chip-val').textContent = s.v;
12920            chip.querySelector('.info-chip-label').textContent = s.l;
12921            inner.classList.remove('fading');
12922          }, 720);
12923        }
12924        setInterval(function() {
12925          chips.forEach(function(chip, i) { advance(i); });
12926        }, 6000);
12927      })();
12928      (function cardLiveData() {
12929        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
12930          var el = document.getElementById('acp-scan-stat');
12931          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
12932        }).catch(function(){});
12933        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
12934          var el = document.getElementById('acp-test-stat');
12935          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
12936        }).catch(function(){});
12937        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
12938          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
12939          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
12940          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
12941          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
12942          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
12943          var stat = document.getElementById('acp-int-stat');
12944          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
12945        }).catch(function(){});
12946        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
12947          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
12948        }).catch(function(){});
12949      })();
12950    })();
12951  </script>
12952  <script nonce="{{ csp_nonce }}">
12953  (function(){
12954    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'}];
12955    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);});}
12956    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12957    function init(){
12958      var btn=document.getElementById('settings-btn');if(!btn)return;
12959      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12960      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>';
12961      document.body.appendChild(m);
12962      var g=document.getElementById('scheme-grid');
12963      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);});
12964      var cl=document.getElementById('settings-close');
12965      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);
12966      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');});
12967      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12968      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12969    }
12970    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12971  }());
12972  </script>
12973</body>
12974</html>
12975"##,
12976    ext = "html"
12977)]
12978struct SplashTemplate {
12979    csp_nonce: String,
12980    server_mode: bool,
12981    lan_ip: Option<String>,
12982    port: u16,
12983    version: &'static str,
12984}
12985
12986// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
12987
12988#[derive(Template)]
12989#[template(
12990    source = r##"
12991<!doctype html>
12992<html lang="en">
12993<head>
12994  <meta charset="utf-8">
12995  <meta name="viewport" content="width=device-width, initial-scale=1">
12996  <title>OxideSLOC — Start a Scan</title>
12997  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12998  <style nonce="{{ csp_nonce }}">
12999    :root {
13000      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
13001      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13002      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13003      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13004      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
13005    }
13006    body.dark-theme {
13007      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
13008      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
13009    }
13010    *{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);}
13011    .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);}
13012    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
13013    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
13014    .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));}
13015    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
13016    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
13017    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
13018    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
13019    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13020    @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; } }
13021    .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;}
13022    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
13023    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
13024    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
13025    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
13026    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
13027    .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;}
13028    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13029    .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);}
13030    .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;}
13031    .settings-close:hover{color:var(--text);background:var(--surface-2);}
13032    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13033    .settings-modal-body{padding:14px 16px 16px;}
13034    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13035    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13036    .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;}
13037    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13038    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13039    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13040    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13041    .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;}
13042    .tz-select:focus{border-color:var(--oxide);}
13043    .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
13044    .page-header{text-align:center;margin-bottom:16px;}
13045    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
13046    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
13047    /* Cards */
13048    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
13049    .option-card-wrap{position:relative;}
13050    .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;}
13051    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
13052    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
13053    .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;}
13054    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
13055    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
13056    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
13057    .card-top-row{display:flex;align-items:center;gap:20px;}
13058    /* Two-column layout inside each card */
13059    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
13060    .card-left{display:flex;align-items:flex-start;min-width:0;}
13061    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
13062    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
13063    .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);}
13064    .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);}
13065    .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);}
13066    .card-text{min-width:0;}
13067    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
13068    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
13069    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
13070    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
13071    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
13072    /* Right CTA column */
13073    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
13074    .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;}
13075    /* Re-scan count badge */
13076    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
13077    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
13078    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
13079    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
13080    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
13081    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
13082    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
13083    body.dark-theme .btn-secondary{color:var(--oxide);}
13084    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
13085    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
13086    /* File input overlay — must be full-width so it aligns with other card-right buttons */
13087    .file-input-wrap{position:relative;width:100%;}
13088    .file-input-wrap .btn{width:100%;}
13089    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
13090    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13091    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
13092    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13093    .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;}
13094    @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));}}
13095    /* Recent list (card 3 — full-width section below header) */
13096    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
13097    .recent-list{display:flex;flex-direction:column;gap:8px;}
13098    .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;}
13099    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
13100    .recent-item-info{flex:1;min-width:0;}
13101    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
13102    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
13103    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
13104    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
13105    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13106    .site-footer a{color:var(--muted);}
13107    @media(max-width:680px){
13108      .card-body{grid-template-columns:1fr;}
13109      .card-right{flex-direction:row;flex-wrap:wrap;}
13110      .btn{flex:1;}
13111    }
13112    .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;}
13113    .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;}
13114    .server-online-pill{cursor:default;}
13115  </style>
13116</head>
13117<body>
13118  <div class="background-watermarks" aria-hidden="true">
13119    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13120    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13121    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13122    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13123    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13124    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13125    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13126  </div>
13127  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13128  <div class="top-nav">
13129    <div class="top-nav-inner">
13130      <a class="brand" href="/">
13131        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13132        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13133      </a>
13134      <div class="nav-right">
13135        <a class="nav-pill" href="/">Home</a>
13136        <div class="nav-dropdown">
13137          <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>
13138          <div class="nav-dropdown-menu">
13139            <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>
13140          </div>
13141        </div>
13142        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13143        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13144        <div class="nav-dropdown">
13145          <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>
13146          <div class="nav-dropdown-menu">
13147            <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>
13148          </div>
13149        </div>
13150        <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13151        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13152          <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>
13153        </button>
13154        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13155          <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>
13156          <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>
13157        </button>
13158      </div>
13159    </div>
13160  </div>
13161
13162  <div class="page">
13163    <div class="page-header">
13164      <h1>How would you like to scan?</h1>
13165      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
13166    </div>
13167
13168    <div class="option-grid">
13169
13170      <!-- Option 1: New scan -->
13171      <div class="option-card-wrap">
13172        <div class="option-card">
13173        <div class="option-icon new-scan">
13174          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
13175        </div>
13176        <div class="card-body">
13177          <div class="card-left">
13178            <div class="card-text">
13179              <div class="option-title">Start a new scan</div>
13180              <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>
13181              <ul class="feature-list">
13182                <li>Live project scope preview before you run</li>
13183                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
13184                <li>HTML, PDF, and JSON output — your choice</li>
13185              </ul>
13186            </div>
13187          </div>
13188          <div class="card-right">
13189            <a class="btn btn-primary" href="/scan">
13190              Configure &amp; scan
13191              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
13192            </a>
13193            <p class="card-tip">Full 4-step setup · all options</p>
13194          </div>
13195        </div>
13196        </div>
13197      </div>
13198
13199      <!-- Option 2: Load from config file -->
13200      <div class="option-card-wrap">
13201        <div class="option-card">
13202        <div class="option-icon load-config">
13203          <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>
13204        </div>
13205        <div class="card-body">
13206          <div class="card-left">
13207            <div class="card-text">
13208              <div class="option-title">Load a saved config</div>
13209              <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>
13210              <ul class="feature-list">
13211                <li>All 15 settings restored from the file</li>
13212                <li>Fully editable — change path or output dir</li>
13213                <li>Works with any scan-config.json</li>
13214              </ul>
13215            </div>
13216          </div>
13217          <div class="card-right">
13218            <div class="file-input-wrap">
13219              <button class="btn btn-secondary" id="load-config-btn" type="button">
13220                <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>
13221                Choose config file
13222              </button>
13223              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
13224            </div>
13225            <p class="card-tip" id="config-file-name">Exported after every scan</p>
13226          </div>
13227        </div>
13228        </div>
13229      </div>
13230
13231      <!-- Option 3: Re-scan recent project -->
13232      <div class="option-card-wrap">
13233        <div class="option-card" id="recent-card">
13234        <div class="card-top-row">
13235          <div class="option-icon rescan">
13236            <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>
13237          </div>
13238          <div class="card-body">
13239            <div class="card-left">
13240              <div class="card-text">
13241                <div class="option-title">Re-scan a recent project</div>
13242                <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>
13243                <ul class="feature-list">
13244                  <li>All 15+ settings restored from the saved config</li>
13245                  <li>Path and output dir are editable before running</li>
13246                  <li>Only scans with a saved config appear here</li>
13247                </ul>
13248              </div>
13249            </div>
13250            <div class="card-right">
13251              <div class="rescan-count-box">
13252                <div class="rescan-count-num" id="rescan-count-num">—</div>
13253                <div class="rescan-count-label">saved configs</div>
13254              </div>
13255              <a class="btn btn-secondary" href="/view-reports">
13256                <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>
13257                View all runs
13258              </a>
13259              <p class="card-tip">Opens run history</p>
13260            </div>
13261          </div>
13262        </div>
13263        <div class="section-divider"></div>
13264        <div class="recent-list" id="recent-list">
13265          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
13266        </div>
13267        </div>
13268      </div>
13269
13270    </div>
13271  </div>
13272
13273  <footer class="site-footer">
13274    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
13275    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13276    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13277    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13278    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13279  </footer>
13280
13281  <script nonce="{{ csp_nonce }}">
13282    (function () {
13283      var storageKey = 'oxide-sloc-theme';
13284      var body = document.body;
13285      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
13286      var toggle = document.getElementById('theme-toggle');
13287      if (toggle) toggle.addEventListener('click', function () {
13288        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
13289        body.classList.toggle('dark-theme', next === 'dark');
13290        try { localStorage.setItem(storageKey, next); } catch(e) {}
13291      });
13292
13293      (function randomizeWatermarks() {
13294        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13295        if (!wms.length) return;
13296        var placed = [];
13297        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; }
13298        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]; }
13299        var half = Math.floor(wms.length / 2);
13300        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; });
13301      })();
13302      (function spawnCodeParticles() {
13303        var container = document.getElementById('code-particles');
13304        if (!container) return;
13305        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'];
13306        var count = 38;
13307        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); }
13308      })();
13309      // Recent scans data injected from server
13310      var recentScans = {{ recent_scans_json|safe }};
13311
13312      function configToParams(cfg) {
13313        var p = new URLSearchParams();
13314        p.set('prefilled', '1');
13315        if (cfg.path) p.set('path', cfg.path);
13316        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
13317        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
13318        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
13319        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
13320        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
13321        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
13322        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
13323        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
13324        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
13325        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
13326        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
13327        if (cfg.report_title) p.set('report_title', cfg.report_title);
13328        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
13329        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
13330        return p;
13331      }
13332
13333      // Build recent scan list (capped at 3 visible entries)
13334      var list = document.getElementById('recent-list');
13335      var noNote = document.getElementById('no-recent-note');
13336      var hasAny = false;
13337      var MAX_RECENT = 3;
13338      if (Array.isArray(recentScans)) {
13339        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
13340        var shown = 0;
13341        validEntries.forEach(function (entry) {
13342          if (shown >= MAX_RECENT) return;
13343          shown++;
13344          hasAny = true;
13345          var item = document.createElement('div');
13346          item.className = 'recent-item';
13347          item.title = 'Restore all settings and open wizard';
13348          item.innerHTML =
13349            '<div class="recent-item-info">' +
13350              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
13351              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
13352            '</div>' +
13353            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
13354          item.addEventListener('click', function () {
13355            var params = configToParams(entry.config);
13356            window.location.href = '/scan?' + params.toString();
13357          });
13358          list.appendChild(item);
13359        });
13360        if (validEntries.length > MAX_RECENT) {
13361          var moreEl = document.createElement('div');
13362          moreEl.className = 'recent-more-link';
13363          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
13364          list.appendChild(moreEl);
13365        }
13366      }
13367      if (hasAny && noNote) noNote.style.display = 'none';
13368      // Update count badge
13369      var countEl = document.getElementById('rescan-count-num');
13370      if (countEl) {
13371        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
13372        countEl.textContent = total > 0 ? total : '0';
13373      }
13374
13375      // Config file loader
13376      var fileInput = document.getElementById('config-file-input');
13377      var fileName = document.getElementById('config-file-name');
13378      if (fileInput) {
13379        fileInput.addEventListener('change', function () {
13380          var file = fileInput.files && fileInput.files[0];
13381          if (!file) return;
13382          if (fileName) fileName.textContent = '✓ ' + file.name;
13383          var reader = new FileReader();
13384          reader.onload = function (e) {
13385            try {
13386              var cfg = JSON.parse(e.target.result);
13387              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
13388              var params = configToParams(cfg);
13389              window.location.href = '/scan?' + params.toString();
13390            } catch (err) {
13391              alert('Could not parse config file: ' + err.message);
13392            }
13393          };
13394          reader.readAsText(file);
13395        });
13396      }
13397
13398      function escHtml(s) {
13399        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
13400      }
13401    })();
13402  </script>
13403  <script nonce="{{ csp_nonce }}">
13404  (function(){
13405    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'}];
13406    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);});}
13407    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
13408    function init(){
13409      var btn=document.getElementById('settings-btn');if(!btn)return;
13410      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13411      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>';
13412      document.body.appendChild(m);
13413      var g=document.getElementById('scheme-grid');
13414      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);});
13415      var cl=document.getElementById('settings-close');
13416      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);
13417      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');});
13418      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
13419      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
13420    }
13421    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13422  }());
13423  </script>
13424</body>
13425</html>
13426"##,
13427    ext = "html"
13428)]
13429struct ScanSetupTemplate {
13430    version: &'static str,
13431    recent_scans_json: String,
13432    csp_nonce: String,
13433}
13434
13435#[derive(Template)]
13436#[template(
13437    source = r##"
13438<!doctype html>
13439<html lang="en">
13440<head>
13441  <meta charset="utf-8">
13442  <meta name="viewport" content="width=device-width, initial-scale=1">
13443  <title>OxideSLOC | {{ report_title }} | Report</title>
13444  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13445  <style nonce="{{ csp_nonce }}">
13446    :root {
13447      --radius: 18px;
13448      --bg: #f5efe8;
13449      --surface: rgba(255,255,255,0.82);
13450      --surface-2: #fbf7f2;
13451      --surface-3: #efe6dc;
13452      --line: #e6d0bf;
13453      --line-strong: #dcb89f;
13454      --text: #43342d;
13455      --muted: #7b675b;
13456      --muted-2: #a08777;
13457      --nav: #b85d33;
13458      --nav-2: #7a371b;
13459      --accent: #6f9bff;
13460      --accent-2: #4a78ee;
13461      --oxide: #d37a4c;
13462      --oxide-2: #b35428;
13463      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
13464      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
13465      --success-bg: #e8f5ed;
13466      --success-text: #1a8f47;
13467      --info-bg: #eef3ff;
13468      --info-text: #4467d8;
13469    }
13470
13471    body.dark-theme {
13472      --bg: #1b1511;
13473      --surface: #261c17;
13474      --surface-2: #2d221d;
13475      --surface-3: #372922;
13476      --line: #524238;
13477      --line-strong: #6c5649;
13478      --text: #f5ece6;
13479      --muted: #c7b7aa;
13480      --muted-2: #aa9485;
13481      --nav: #b85d33;
13482      --nav-2: #7a371b;
13483      --accent: #6f9bff;
13484      --accent-2: #4a78ee;
13485      --oxide: #d37a4c;
13486      --oxide-2: #b35428;
13487      --shadow: 0 18px 42px rgba(0,0,0,0.28);
13488      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
13489      --success-bg: #163927;
13490      --success-text: #8fe2a8;
13491      --info-bg: #1c2847;
13492      --info-text: #a9c1ff;
13493    }
13494
13495    * { box-sizing: border-box; }
13496    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); }
13497    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
13498    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
13499    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
13500    .top-nav, .page { position: relative; z-index: 2; }
13501    .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); }
13502    .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; }
13503    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
13504    .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)); }
13505    .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; }
13506    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
13507    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
13508    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
13509    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
13510    .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; }
13511    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
13512    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
13513    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
13514    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13515    @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; } }
13516    .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; }
13517    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
13518    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
13519    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
13520    .theme-toggle .icon-sun { display:none; }
13521    body.dark-theme .theme-toggle .icon-sun { display:block; }
13522    body.dark-theme .theme-toggle .icon-moon { display:none; }
13523    .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;}
13524    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13525    .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);}
13526    .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;}
13527    .settings-close:hover{color:var(--text);background:var(--surface-2);}
13528    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13529    .settings-modal-body{padding:14px 16px 16px;}
13530    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13531    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13532    .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;}
13533    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13534    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13535    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13536    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13537    .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;}
13538    .tz-select:focus{border-color:var(--oxide);}
13539    .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; }
13540    .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;}
13541    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
13542    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
13543    .hero, .panel { padding: 22px; }
13544    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
13545    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
13546    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
13547    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
13548    .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; }
13549    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
13550    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
13551    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
13552    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
13553    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
13554    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
13555    .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; }
13556    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
13557    .delta-card-val { font-size:16px; font-weight:800; }
13558    .delta-card-val.pos { color:#1e7e34; }
13559    .delta-card-val.neg { color:var(--neg); }
13560    .delta-card-val.mod { color:#b35428; }
13561    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
13562    .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; }
13563    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13564    .delta-card-inline:hover .delta-card-tip { opacity:1; }
13565    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
13566    .compare-ts { font-size:13px; color:var(--muted); }
13567    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
13568    .compare-arrow { color: var(--muted); }
13569    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
13570    .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; }
13571    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
13572    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
13573    .button, .copy-button {
13574      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;
13575    }
13576    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
13577    @keyframes spin { to { transform: rotate(360deg); } }
13578    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
13579    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
13580    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
13581    .path-item strong { display: block; margin-bottom: 6px; }
13582    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
13583    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
13584    .path-subitem { flex: 1; }
13585    .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); }
13586    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); }
13587    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
13588    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
13589    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
13590    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
13591    th { color: var(--muted); font-weight: 700; }
13592    tr:last-child td { border-bottom: none; }
13593    #subm-tbl col:nth-child(1){width:15%;}
13594    #subm-tbl col:nth-child(2){width:31%;}
13595    #subm-tbl col:nth-child(3){width:9%;}
13596    #subm-tbl col:nth-child(4){width:9%;}
13597    #subm-tbl col:nth-child(5){width:9%;}
13598    #subm-tbl col:nth-child(6){width:9%;}
13599    #subm-tbl col:nth-child(7){width:9%;}
13600    #subm-tbl col:nth-child(8){width:9%;}
13601    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
13602    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
13603    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
13604    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
13605    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
13606    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
13607    .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; }
13608    .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; }
13609    .soft-chip.success svg { flex:0 0 auto; }
13610    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); }
13611    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
13612    .muted { color: var(--muted); }
13613    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13614    .site-footer a{color:var(--muted);}
13615    .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; }
13616    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
13617    .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; }
13618    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
13619    /* Stat chips (matches HTML report) */
13620    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
13621    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
13622    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
13623    .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; }
13624    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
13625    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
13626    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
13627    .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; }
13628    .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; }
13629    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13630    .stat-chip:hover .stat-chip-tip { opacity:1; }
13631    /* Submodule panel */
13632    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
13633    /* Metrics tables stack */
13634    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
13635    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
13636    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
13637    .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)); }
13638    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
13639    /* Metrics table */
13640    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
13641    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
13642    .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; }
13643    .metrics-table thead th:not(:first-child) { text-align: right; }
13644    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
13645    .metrics-table tbody tr:last-child td { border-bottom: none; }
13646    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
13647    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
13648    .metrics-table tbody tr:hover td { background: var(--surface-2); }
13649    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
13650    .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; }
13651    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
13652    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
13653    .mt-val-pos { color: var(--pos); font-weight: 700; }
13654    .mt-val-neg { color: var(--neg); font-weight: 700; }
13655    .mt-val-zero { color: var(--muted); }
13656    .mt-val-mod { color: var(--oxide-2); }
13657    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
13658    @media (max-width: 1180px) {
13659      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
13660      .nav-project-slot, .nav-status { justify-content:flex-start; }
13661      .hero-top { flex-direction: column; }
13662    }
13663    .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;}
13664    @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));}}
13665    .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;}
13666    /* ── Result-page chart controls ─────────────────────────────────────────── */
13667    .r-chart-section{margin-bottom:24px;}
13668    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
13669    .section-pair > .panel{flex-shrink:0;}
13670    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
13671    .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;}
13672    .r-chart-select:focus{border-color:var(--accent);}
13673    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
13674    .r-chart-container svg{display:block;width:100%;height:auto;}
13675    .r-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
13676    .r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
13677    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
13678    .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;}
13679    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
13680    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
13681    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
13682    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
13683    #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;}
13684    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
13685    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
13686    .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;}
13687    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
13688    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
13689    .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface-2);display:flex;flex-direction:column;}
13690    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
13691    .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%;}
13692    .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%;}
13693    body.has-report-banner .top-nav{top:27px;}
13694    body.has-report-banner{padding-bottom:27px;}
13695  </style>
13696</head>
13697<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
13698  <div class="background-watermarks" aria-hidden="true">
13699    <img src="/images/logo/logo-text.png" alt="" />
13700    <img src="/images/logo/logo-text.png" alt="" />
13701    <img src="/images/logo/logo-text.png" alt="" />
13702    <img src="/images/logo/logo-text.png" alt="" />
13703    <img src="/images/logo/logo-text.png" alt="" />
13704    <img src="/images/logo/logo-text.png" alt="" />
13705    <img src="/images/logo/logo-text.png" alt="" />
13706    <img src="/images/logo/logo-text.png" alt="" />
13707    <img src="/images/logo/logo-text.png" alt="" />
13708    <img src="/images/logo/logo-text.png" alt="" />
13709    <img src="/images/logo/logo-text.png" alt="" />
13710    <img src="/images/logo/logo-text.png" alt="" />
13711    <img src="/images/logo/logo-text.png" alt="" />
13712    <img src="/images/logo/logo-text.png" alt="" />
13713  </div>
13714  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13715  {% if let Some(banner) = report_header_footer %}
13716  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
13717  {% endif %}
13718  <div class="top-nav">
13719    <div class="top-nav-inner">
13720      <a class="brand" href="/">
13721        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13722        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13723      </a>
13724      <div class="nav-project-slot">
13725        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
13726      </div>
13727      <div class="nav-status">
13728        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
13729        <div class="nav-dropdown">
13730          <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>
13731          <div class="nav-dropdown-menu">
13732            <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>
13733          </div>
13734        </div>
13735        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
13736        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13737        <div class="nav-dropdown">
13738          <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>
13739          <div class="nav-dropdown-menu">
13740            <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>
13741          </div>
13742        </div>
13743        <div class="server-status-wrap">
13744          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13745          <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>
13746        </div>
13747        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13748          <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>
13749        </button>
13750        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13751          <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>
13752          <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>
13753        </button>
13754      </div>
13755    </div>
13756  </div>
13757
13758  <div class="page">
13759    <section class="hero">
13760      <div class="hero-top">
13761        <div>
13762          <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>
13763          <h1 class="hero-title">{{ report_title }}</h1>
13764          <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>
13765        </div>
13766        <div class="hero-quick-actions">
13767          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
13768          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
13769          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
13770        </div>
13771      </div>
13772
13773      <div class="summary-strip">
13774        <div class="stat-chip" data-raw="{{ physical_lines }}">
13775          <div class="stat-chip-label">Physical Lines</div>
13776          <div class="stat-chip-val">{{ physical_lines }}</div>
13777          <div class="stat-chip-exact"></div>
13778          <div class="stat-chip-tip">Total physical lines including code, comments, and blank lines</div>
13779        </div>
13780        <div class="stat-chip" data-raw="{{ code_lines }}">
13781          <div class="stat-chip-label">Code</div>
13782          <div class="stat-chip-val">{{ code_lines }}</div>
13783          <div class="stat-chip-exact"></div>
13784          <div class="stat-chip-tip">Executable source lines (IEEE 1045 SLOC)</div>
13785        </div>
13786        <div class="stat-chip" data-raw="{{ comment_lines }}">
13787          <div class="stat-chip-label">Comments</div>
13788          <div class="stat-chip-val">{{ comment_lines }}</div>
13789          <div class="stat-chip-exact"></div>
13790          <div class="stat-chip-tip">Lines classified as comments or documentation</div>
13791        </div>
13792        <div class="stat-chip" data-raw="{{ blank_lines }}">
13793          <div class="stat-chip-label">Blank</div>
13794          <div class="stat-chip-val">{{ blank_lines }}</div>
13795          <div class="stat-chip-exact"></div>
13796          <div class="stat-chip-tip">Empty or whitespace-only lines</div>
13797        </div>
13798        <div class="stat-chip" data-raw="{{ files_analyzed }}">
13799          <div class="stat-chip-label">Files Analyzed</div>
13800          <div class="stat-chip-val">{{ files_analyzed }}</div>
13801          <div class="stat-chip-exact"></div>
13802          <div class="stat-chip-tip">Source files successfully parsed and counted</div>
13803        </div>
13804        <div class="stat-chip" data-raw="{{ functions }}">
13805          <div class="stat-chip-label">Functions</div>
13806          <div class="stat-chip-val">{{ functions }}</div>
13807          <div class="stat-chip-exact"></div>
13808          <div class="stat-chip-tip">Best-effort count of function and method definitions</div>
13809        </div>
13810      </div>
13811
13812      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
13813      <div class="compare-banner">
13814        <div class="compare-banner-body">
13815          <div class="compare-banner-meta">
13816            <span class="compare-label">Previous scan</span>
13817            <span class="compare-ts">{{ prev_ts }}</span>
13818            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
13819            {% if let Some(prev_code) = prev_run_code_lines %}
13820            <div class="compare-banner-stats" style="margin-top:4px;">
13821              <span>Code before: <strong>{{ prev_code }}</strong></span>
13822              <span class="compare-arrow">→</span>
13823              <span>Code now: <strong>{{ code_lines }}</strong></span>
13824              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
13825              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
13826            </div>
13827            {% endif %}
13828          </div>
13829          {% if delta_lines_added.is_some() %}
13830          <div class="delta-cards-inline">
13831            <div class="delta-card-inline">
13832              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
13833              <div class="delta-card-lbl">lines added</div>
13834              <div class="delta-card-tip">Code lines added since the previous scan</div>
13835            </div>
13836            <div class="delta-card-inline">
13837              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
13838              <div class="delta-card-lbl">lines removed</div>
13839              <div class="delta-card-tip">Code lines removed since the previous scan</div>
13840            </div>
13841            <div class="delta-card-inline">
13842              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
13843              <div class="delta-card-lbl">unmodified lines</div>
13844              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
13845            </div>
13846            <div class="delta-card-inline">
13847              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
13848              <div class="delta-card-lbl">files modified</div>
13849              <div class="delta-card-tip">Files with at least one line changed</div>
13850            </div>
13851            <div class="delta-card-inline">
13852              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
13853              <div class="delta-card-lbl">files added</div>
13854              <div class="delta-card-tip">New files added since the previous scan</div>
13855            </div>
13856            <div class="delta-card-inline">
13857              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
13858              <div class="delta-card-lbl">files removed</div>
13859              <div class="delta-card-tip">Files deleted since the previous scan</div>
13860            </div>
13861            <div class="delta-card-inline">
13862              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
13863              <div class="delta-card-lbl">files unchanged</div>
13864              <div class="delta-card-tip">Files with no changes since the previous scan</div>
13865            </div>
13866          </div>
13867          {% else %}
13868          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
13869            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
13870          </p>
13871          {% endif %}
13872          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
13873        </div>
13874      </div>
13875      {% endif %}{% endif %}
13876
13877      <div class="action-grid">
13878        <div class="action-card">
13879          <h3>HTML report</h3>
13880          <div class="action-buttons">
13881            {% match html_url %}
13882              {% when Some with (url) %}
13883                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
13884              {% when None %}{% endmatch %}
13885            {% match html_download_url %}
13886              {% when Some with (url) %}
13887                <a class="button secondary" href="{{ url }}">Download HTML</a>
13888              {% when None %}{% endmatch %}
13889            {% match html_path %}
13890              {% when Some with (_path) %}{% when None %}{% endmatch %}
13891            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
13892          </div>
13893        </div>
13894        <div class="action-card">
13895          <h3>PDF report</h3>
13896          <div class="action-buttons">
13897            {% match pdf_url %}
13898              {% when Some with (url) %}
13899                {% if pdf_generating %}
13900                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
13901                    <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>
13902                    Generating PDF…
13903                  </button>
13904                {% else %}
13905                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
13906                {% endif %}
13907              {% when None %}{% endmatch %}
13908            {% match pdf_download_url %}
13909              {% when Some with (url) %}
13910                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
13911              {% when None %}{% endmatch %}
13912            {% match pdf_path %}
13913              {% when Some with (_path) %}{% when None %}{% endmatch %}
13914            <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
13915          </div>
13916        </div>
13917        <div class="action-card">
13918          <h3>JSON result</h3>
13919          <div class="action-buttons">
13920            {% match json_url %}
13921              {% when Some with (url) %}
13922                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
13923              {% when None %}{% endmatch %}
13924            {% match json_download_url %}
13925              {% when Some with (url) %}
13926                <a class="button secondary" href="{{ url }}">Download JSON</a>
13927              {% when None %}{% endmatch %}
13928            {% match json_path %}
13929              {% when Some with (_path) %}
13930                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
13931              {% when None %}
13932                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
13933              {% endmatch %}
13934          </div>
13935        </div>
13936        <div class="action-card">
13937          <h3>Scan config</h3>
13938          <div class="action-buttons">
13939            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
13940            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
13941            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
13942          </div>
13943        </div>
13944        {% if confluence_configured %}
13945        <div class="action-card" id="confluenceCard">
13946          <h3>Confluence</h3>
13947          <div class="action-buttons">
13948            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
13949            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
13950          </div>
13951          <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>
13952        </div>
13953        {% endif %}
13954      </div>
13955      {% if confluence_configured %}
13956      <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;">
13957        <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);">
13958          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
13959          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
13960          <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;">
13961          <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>
13962          <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;">
13963          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
13964          <div style="display:flex;gap:10px;justify-content:flex-end;">
13965            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
13966            <button class="button" id="confSubmitBtn" type="button">Post</button>
13967          </div>
13968        </div>
13969      </div>
13970      {% endif %}
13971      {% if !submodule_rows.is_empty() %}
13972      <div class="submodule-panel">
13973        <div class="toolbar-row">
13974          <div>
13975            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
13976            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
13977          </div>
13978          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
13979        </div>
13980        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
13981        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
13982          <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>
13983          <thead>
13984            <tr>
13985              <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>
13986              <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>
13987              <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>
13988              <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>
13989              <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>
13990              <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>
13991              <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>
13992              <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>
13993            </tr>
13994          </thead>
13995          <tbody>
13996            {% for row in submodule_rows %}
13997            <tr>
13998              <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>
13999              <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>
14000              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
14001              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
14002              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
14003              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
14004              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
14005              <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>
14006            </tr>
14007            {% endfor %}
14008          </tbody>
14009        </table>
14010        </div>
14011      </div>
14012      {% endif %}
14013
14014      <div class="metrics-tables-stack">
14015
14016        <div class="metrics-table-wrap">
14017          <div class="metrics-table-title">Files</div>
14018          <table class="metrics-table">
14019            <thead>
14020              <tr>
14021                <th>Metric</th>
14022                <th>This Run</th>
14023                <th>Previous</th>
14024                <th>Change</th>
14025              </tr>
14026            </thead>
14027            <tbody>
14028              <tr>
14029                <td>Files analyzed</td>
14030                <td class="mt-val-large">{{ files_analyzed }}</td>
14031                <td>{{ prev_fa_str }}</td>
14032                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
14033              </tr>
14034              <tr>
14035                <td>Files skipped</td>
14036                <td>{{ files_skipped }}</td>
14037                <td>{{ prev_fs_str }}</td>
14038                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
14039              </tr>
14040              <tr>
14041                <td>Files modified</td>
14042                <td class="mt-val-na">—</td>
14043                <td class="mt-val-na">—</td>
14044                <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>
14045              </tr>
14046              <tr>
14047                <td>Files unchanged</td>
14048                <td class="mt-val-na">—</td>
14049                <td class="mt-val-na">—</td>
14050                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
14051              </tr>
14052            </tbody>
14053          </table>
14054        </div>
14055
14056        <div class="metrics-table-wrap">
14057          <div class="metrics-table-title">Line Counts</div>
14058          <table class="metrics-table">
14059            <thead>
14060              <tr>
14061                <th>Metric</th>
14062                <th>This Run</th>
14063                <th>Previous</th>
14064                <th>Change</th>
14065              </tr>
14066            </thead>
14067            <tbody>
14068              <tr>
14069                <td>Physical lines</td>
14070                <td class="mt-val-large">{{ physical_lines }}</td>
14071                <td>{{ prev_pl_str }}</td>
14072                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
14073              </tr>
14074              <tr>
14075                <td>Code lines</td>
14076                <td class="mt-val-large">{{ code_lines }}</td>
14077                <td>{{ prev_cl_str }}</td>
14078                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
14079              </tr>
14080              <tr>
14081                <td>Comment lines</td>
14082                <td>{{ comment_lines }}</td>
14083                <td>{{ prev_cml_str }}</td>
14084                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
14085              </tr>
14086              <tr>
14087                <td>Blank lines</td>
14088                <td>{{ blank_lines }}</td>
14089                <td>{{ prev_bl_str }}</td>
14090                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
14091              </tr>
14092              <tr>
14093                <td>Mixed (separate)</td>
14094                <td>{{ mixed_lines }}</td>
14095                <td class="mt-val-na">—</td>
14096                <td class="mt-val-na">—</td>
14097              </tr>
14098            </tbody>
14099          </table>
14100        </div>
14101
14102        <div class="metrics-tables-lower">
14103          <div class="metrics-table-wrap">
14104            <div class="metrics-table-title">Code Structure</div>
14105            <table class="metrics-table">
14106              <thead>
14107                <tr>
14108                  <th>Metric</th>
14109                  <th>This Run</th>
14110                </tr>
14111              </thead>
14112              <tbody>
14113                <tr>
14114                  <td>Functions</td>
14115                  <td>{{ functions }}</td>
14116                </tr>
14117                <tr>
14118                  <td>Classes / Types</td>
14119                  <td>{{ classes }}</td>
14120                </tr>
14121                <tr>
14122                  <td>Variables</td>
14123                  <td>{{ variables }}</td>
14124                </tr>
14125                <tr>
14126                  <td>Imports</td>
14127                  <td>{{ imports }}</td>
14128                </tr>
14129              </tbody>
14130            </table>
14131          </div>
14132
14133          <div class="metrics-table-wrap">
14134            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
14135            <table class="metrics-table">
14136              <thead>
14137                <tr>
14138                  <th>Metric</th>
14139                  <th>Change</th>
14140                </tr>
14141              </thead>
14142              <tbody>
14143                <tr>
14144                  <td>Lines added</td>
14145                  <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>
14146                </tr>
14147                <tr>
14148                  <td>Lines removed</td>
14149                  <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>
14150                </tr>
14151                <tr>
14152                  <td>Lines modified (net)</td>
14153                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
14154                </tr>
14155                <tr>
14156                  <td>Lines unmodified</td>
14157                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
14158                </tr>
14159              </tbody>
14160            </table>
14161          </div>
14162        </div>
14163
14164      </div>
14165
14166      <div class="path-list">
14167        <div class="path-item">
14168          <div class="path-item-label">Project path</div>
14169          <code>{{ project_path }}</code>
14170        </div>
14171        <div class="path-item">
14172          <div class="path-item-label">Git branch</div>
14173          {% if let Some(branch) = git_branch %}
14174          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
14175          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
14176          {% else %}
14177          <code style="color:var(--muted)">—</code>
14178          {% endif %}
14179        </div>
14180        <div class="path-item">
14181          <div class="path-item-label">Output folder</div>
14182          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
14183        </div>
14184        <div class="path-item">
14185          <div class="path-item-label">Run ID</div>
14186          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
14187            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
14188            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
14189          </div>
14190        </div>
14191      </div>
14192    </section>
14193
14194    <div id="r-tt" aria-hidden="true"></div>
14195
14196    <div class="section-pair">
14197    <section class="panel">
14198        <div class="toolbar-row">
14199          <div>
14200            <h2>Language breakdown</h2>
14201            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
14202          </div>
14203        </div>
14204        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
14205    </section>
14206
14207    <section class="panel r-chart-section">
14208      <div class="toolbar-row" style="margin-bottom:16px;">
14209        <div>
14210          <h2>Visualizations</h2>
14211          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
14212        </div>
14213      </div>
14214
14215      <div class="r-viz-grid">
14216        <div class="r-viz-card">
14217          <p class="r-viz-card-title">Language Composition</p>
14218          <div class="r-chart-tab-bar">
14219            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
14220            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
14221          </div>
14222          <div class="r-chart-container" id="r-composition-chart"></div>
14223        </div>
14224        <div class="r-viz-card">
14225          <p class="r-viz-card-title">Files vs Code Lines</p>
14226          <div class="r-chart-container" id="r-scatter-chart"></div>
14227        </div>
14228        {% if has_semantic_data %}
14229        <div class="r-viz-card">
14230          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14231            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
14232            <select class="r-chart-select" id="r-semantic-metric">
14233              <option value="functions">Functions</option>
14234              <option value="classes">Classes</option>
14235              <option value="variables">Variables</option>
14236              <option value="imports">Imports</option>
14237            </select>
14238          </div>
14239          <div class="r-chart-container" id="r-semantic-chart"></div>
14240        </div>
14241        {% endif %}
14242        {% if has_submodule_data %}
14243        <div class="r-viz-card">
14244          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14245            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Submodule Breakdown</p>
14246            <select class="r-chart-select" id="r-sub-metric">
14247              <option value="code">Code Lines</option>
14248              <option value="comment">Comments</option>
14249              <option value="blank">Blank Lines</option>
14250              <option value="physical">Physical Lines</option>
14251              <option value="files">Files</option>
14252            </select>
14253            <select class="r-chart-select" id="r-sub-sort">
14254              <option value="desc">Value ↓</option>
14255              <option value="asc">Value ↑</option>
14256              <option value="name">Name A→Z</option>
14257            </select>
14258          </div>
14259          <div class="r-chart-container" id="r-submodule-chart"></div>
14260        </div>
14261        {% endif %}
14262      </div>
14263
14264    </section>
14265    </div>
14266
14267  </div>
14268
14269  <script nonce="{{ csp_nonce }}">
14270    (function () {
14271      var body = document.body;
14272      var themeToggle = document.getElementById('theme-toggle');
14273      var storageKey = 'oxide-sloc-theme';
14274
14275      function applyTheme(theme) {
14276        body.classList.toggle('dark-theme', theme === 'dark');
14277      }
14278
14279      function loadSavedTheme() {
14280        try {
14281          var saved = localStorage.getItem(storageKey);
14282          if (saved === 'dark' || saved === 'light') {
14283            applyTheme(saved);
14284          }
14285        } catch (e) {}
14286      }
14287
14288      if (themeToggle) {
14289        themeToggle.addEventListener('click', function () {
14290          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
14291          applyTheme(nextTheme);
14292          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
14293        });
14294      }
14295
14296      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
14297        button.addEventListener('click', function () {
14298          var value = button.getAttribute('data-copy-value') || '';
14299          if (!value) return;
14300          if (navigator.clipboard && navigator.clipboard.writeText) {
14301            navigator.clipboard.writeText(value).catch(function () {});
14302          }
14303        });
14304      });
14305
14306      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14307        btn.addEventListener('click', function () {
14308          var folder = btn.getAttribute('data-folder') || '';
14309          if (!folder) return;
14310          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
14311        });
14312      });
14313
14314      loadSavedTheme();
14315
14316      // ── Compact number formatting for stat chips ──────────────────────────
14317      (function(){
14318        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();}
14319        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
14320          var raw=parseInt(chip.getAttribute('data-raw'),10);
14321          if(isNaN(raw))return;
14322          var valEl=chip.querySelector('.stat-chip-val');
14323          if(valEl)valEl.textContent=fmt(raw);
14324          var exactEl=chip.querySelector('.stat-chip-exact');
14325          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
14326        });
14327      })();
14328
14329      // ── Shared tooltip for all result-page charts ─────────────────────────
14330      var rTT=(function(){
14331        var el=document.getElementById('r-tt');
14332        if(!el)return{s:function(){},h:function(){},m:function(){}};
14333        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
14334        function hide(){el.style.display='none';}
14335        function move(e){
14336          var x=e.clientX+16,y=e.clientY-12;
14337          var r=el.getBoundingClientRect();
14338          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
14339          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
14340          el.style.left=x+'px';el.style.top=y+'px';
14341        }
14342        return{s:show,h:hide,m:move};
14343      })();
14344      window.rTT=rTT;
14345
14346      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
14347      (function(){
14348        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14349        document.addEventListener('mouseover',function(e){
14350          var t=e.target;
14351          while(t&&t.getAttribute){
14352            var l=t.getAttribute('data-ttl');
14353            if(l!==null){
14354              var v=t.getAttribute('data-ttv')||'';
14355              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
14356              return;
14357            }
14358            t=t.parentNode;
14359          }
14360        });
14361        document.addEventListener('mouseout',function(e){
14362          var t=e.target;
14363          while(t&&t.getAttribute){
14364            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
14365            t=t.parentNode;
14366          }
14367        });
14368        document.addEventListener('mousemove',function(e){
14369          var el=document.getElementById('r-tt');
14370          if(el&&el.style.display!=='none')rTT.m(e);
14371        });
14372      })();
14373
14374      // ── Language overview charts ───────────────────────────────────────────
14375      (function(){
14376        var D={{ lang_chart_json|safe }};
14377        if(!D||!D.length)return;
14378        var el=document.getElementById('result-lang-charts');
14379        if(!el)return;
14380        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14381        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
14382        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14383        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();}
14384        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14385        function px(n){return Math.round(n);}
14386        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+'"';}
14387        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
14388
14389        // Donut chart — fixed 240×240 viewBox, legend to the right inside the SVG
14390        var cx=100,cy=110,Ro=88,Ri=48;
14391        var legX=204,DW=360,DH=220;
14392        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">';
14393        if(D.length===1){
14394          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
14395          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+'"/>';
14396        } else {
14397          var ang=-Math.PI/2;
14398          D.forEach(function(d,i){
14399            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
14400            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
14401            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
14402            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
14403            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
14404            var pct=Math.round(d.code/tot*100);
14405            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"/>';
14406            ang+=sw;
14407          });
14408        }
14409        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
14410        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
14411        var legRows=Math.min(D.length,8);
14412        var legYStart=Math.round((DH-legRows*22)/2);
14413        D.forEach(function(d,i){
14414          if(i>=8)return;
14415          var ly=legYStart+i*22;
14416          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
14417          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
14418        });
14419        ds+='</svg>';
14420
14421        // Horizontal stacked-bar chart — fills container width
14422        var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
14423        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
14424        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">';
14425        D.forEach(function(d,i){
14426          var y=6+i*rHb,x=LW;
14427          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
14428          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>';
14429          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;
14430          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;
14431          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"/>';
14432          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>';
14433        });
14434        var ly=SH-14;
14435        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>';
14436        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>';
14437        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>';
14438        bs+='</svg>';
14439        el.innerHTML='<div class="r-lang-overview">'+
14440          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
14441          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
14442        '</div>';
14443      })();
14444
14445      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
14446      (function(){
14447        var LANG_D={{ lang_chart_json|safe }};
14448        var SCAT_D={{ scatter_chart_json|safe }};
14449        var SEM_D={{ semantic_chart_json|safe }};
14450        var SUB_D={{ submodule_chart_json|safe }};
14451        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
14452        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14453        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();}
14454        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14455        function px(n){return Math.round(n);}
14456        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+'"';}
14457
14458        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
14459        function renderComposition(mode){
14460          var el=document.getElementById('r-composition-chart');
14461          if(!el||!LANG_D||!LANG_D.length)return;
14462          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14463          var LW=110,SH=224;
14464          var svgW=Math.max(320,el.offsetWidth||480);
14465          var BW=Math.max(120,svgW-LW-80);
14466          var legendH=24,topPad=4;
14467          var n=LANG_D.length||1;
14468          var rowTotal=Math.floor((SH-legendH-topPad)/n);
14469          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
14470          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">';
14471          if(mode==='pct'){
14472            LANG_D.forEach(function(d,i){
14473              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
14474              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
14475              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14476              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>';
14477              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;
14478              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;
14479              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+'"/>';
14480              var pct=Math.round((d.code||0)/tot2*100);
14481              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
14482            });
14483          } else {
14484            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
14485            LANG_D.forEach(function(d,i){
14486              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
14487              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14488              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>';
14489              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;
14490              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;
14491              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+'"/>';
14492              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>';
14493            });
14494          }
14495          var ly=SH-legendH+4;
14496          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>';
14497          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>';
14498          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>';
14499          s+='</svg>';
14500          el.innerHTML=s;
14501        }
14502        renderComposition('abs');
14503        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
14504          btn.addEventListener('click',function(){
14505            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
14506            btn.classList.add('active');
14507            renderComposition(btn.getAttribute('data-rcomp'));
14508          });
14509        });
14510
14511        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
14512        (function(){
14513          var el=document.getElementById('r-scatter-chart');
14514          if(!el||!SCAT_D||!SCAT_D.length)return;
14515          var H=224,PL=52,PB=36,PT=12,PR=14;
14516          var W=Math.max(320,el.offsetWidth||480);
14517          var cW=W-PL-PR,cH=H-PT-PB;
14518          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14519          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14520          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14521          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">';
14522          [0,0.25,0.5,0.75,1].forEach(function(t){
14523            var y=PT+cH*(1-t);
14524            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14525            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>';
14526          });
14527          [0,0.25,0.5,0.75,1].forEach(function(t){
14528            var x=PL+cW*t;
14529            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14530            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>';
14531          });
14532          SCAT_D.forEach(function(d,i){
14533            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
14534            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
14535            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"/>';
14536            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>';
14537          });
14538          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>';
14539          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>';
14540          s+='</svg>';
14541          el.innerHTML=s;
14542        })();
14543
14544        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
14545        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
14546        // the old vertical column layout on wide containers.
14547        function renderSemantic(key){
14548          var el=document.getElementById('r-semantic-chart');
14549          if(!el||!SEM_D||!SEM_D.length)return;
14550          var LW=112,SH=224;
14551          var svgW=Math.max(320,el.offsetWidth||480);
14552          var BW=Math.max(120,svgW-LW-80);
14553          var topPad=4,botPad=14;
14554          var n2=SEM_D.length||1;
14555          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
14556          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
14557          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
14558          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">';
14559          SEM_D.forEach(function(d,i){
14560            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
14561            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>';
14562            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"/>';
14563            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>';
14564          });
14565          s+='</svg>';
14566          el.innerHTML=s;
14567        }
14568        var semSel=document.getElementById('r-semantic-metric');
14569        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
14570
14571        // ── Submodule: horizontal bar chart ────────────────────────────────────
14572        function renderSubmodule(key,sort){
14573          var el=document.getElementById('r-submodule-chart');
14574          if(!el||!SUB_D||!SUB_D.length)return;
14575          var data=SUB_D.slice();
14576          if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
14577          else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
14578          else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
14579          var LW=128,SH=224;
14580          var svgW=Math.max(320,el.offsetWidth||480);
14581          var BW=Math.max(120,svgW-LW-80);
14582          var topPad3=4,botPad3=14;
14583          var n3=data.length||1;
14584          var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
14585          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
14586          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
14587          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">';
14588          data.forEach(function(d,i){
14589            var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-bH)/2);
14590            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>';
14591            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"/>';
14592            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>';
14593          });
14594          s+='</svg>';
14595          el.innerHTML=s;
14596        }
14597        var subSel=document.getElementById('r-sub-metric');
14598        var sortSel=document.getElementById('r-sub-sort');
14599        if(subSel){
14600          renderSubmodule('code','desc');
14601          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
14602          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
14603        }
14604
14605        // Re-render all SVG charts when the window is resized so bars fill the card.
14606        var _rResizeTimer;
14607        window.addEventListener('resize',function(){
14608          clearTimeout(_rResizeTimer);
14609          _rResizeTimer=setTimeout(function(){
14610            var rcompBtn=document.querySelector('[data-rcomp].active');
14611            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
14612            (function(){
14613              var scEl=document.getElementById('r-scatter-chart');
14614              if(!scEl||!SCAT_D||!SCAT_D.length)return;
14615              var H=224,PL=52,PB=36,PT=12,PR=14;
14616              var W=Math.max(320,scEl.offsetWidth||480);
14617              var cW=W-PL-PR,cH=H-PT-PB;
14618              var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14619              var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14620              var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14621              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">';
14622              [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>';});
14623              [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>';});
14624              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>';});
14625              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>';
14626              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>';
14627              s+='</svg>';scEl.innerHTML=s;
14628            })();
14629            if(semSel)renderSemantic(semSel.value||'functions');
14630            if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
14631          },120);
14632        });
14633      })();
14634
14635      (function randomizeWatermarks() {
14636        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14637        if (!wms.length) return;
14638        var placed = [];
14639        function tooClose(top, left) {
14640          for (var i = 0; i < placed.length; i++) {
14641            var dt = Math.abs(placed[i][0] - top);
14642            var dl = Math.abs(placed[i][1] - left);
14643            if (dt < 20 && dl < 18) return true;
14644          }
14645          return false;
14646        }
14647        function pick(leftBand) {
14648          for (var attempt = 0; attempt < 50; attempt++) {
14649            var top = Math.random() * 85 + 5;
14650            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14651            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14652          }
14653          var top = Math.random() * 85 + 5;
14654          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14655          placed.push([top, left]);
14656          return [top, left];
14657        }
14658        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
14659        var half = Math.floor(wms.length / 2);
14660        wms.forEach(function (img, i) {
14661          var pos = pick(i < half);
14662          var size = Math.floor(Math.random() * 100 + 160);
14663          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
14664          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
14665          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;
14666        });
14667      })();
14668
14669      (function spawnCodeParticles() {
14670        var container = document.getElementById('code-particles');
14671        if (!container) return;
14672        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'];
14673        for (var i = 0; i < 38; i++) {
14674          (function(idx) {
14675            var el = document.createElement('span');
14676            el.className = 'code-particle';
14677            el.textContent = snippets[idx % snippets.length];
14678            var left = Math.random() * 94 + 2;
14679            var top = Math.random() * 88 + 6;
14680            var dur = (Math.random() * 10 + 9).toFixed(1);
14681            var delay = (Math.random() * 18).toFixed(1);
14682            var rot = (Math.random() * 26 - 13).toFixed(1);
14683            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14684            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';
14685            container.appendChild(el);
14686          })(i);
14687        }
14688      })();
14689
14690      {% if pdf_generating %}
14691      // Poll for PDF readiness and swap the disabled button to a live link once done.
14692      (function() {
14693        var openBtn = document.getElementById('pdf-open-btn');
14694        var dlBtn = document.getElementById('pdf-download-btn');
14695        function checkPdf() {
14696          fetch('/api/runs/{{ run_id }}/pdf-status')
14697            .then(function(r) { return r.json(); })
14698            .then(function(d) {
14699              if (d.ready) {
14700                if (openBtn) {
14701                  var a = document.createElement('a');
14702                  a.className = 'button';
14703                  a.id = 'pdf-open-btn';
14704                  a.href = '/runs/pdf/{{ run_id }}';
14705                  a.target = '_blank';
14706                  a.rel = 'noopener';
14707                  a.textContent = 'Open PDF';
14708                  openBtn.replaceWith(a);
14709                }
14710                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
14711              } else {
14712                setTimeout(checkPdf, 3000);
14713              }
14714            })
14715            .catch(function() { setTimeout(checkPdf, 5000); });
14716        }
14717        setTimeout(checkPdf, 3000);
14718      })();
14719      {% endif %}
14720
14721    })();
14722  </script>
14723  <script nonce="{{ csp_nonce }}">
14724  (function(){
14725    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'}];
14726    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);});}
14727    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14728    function init(){
14729      var btn=document.getElementById('settings-btn');if(!btn)return;
14730      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14731      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>';
14732      document.body.appendChild(m);
14733      var g=document.getElementById('scheme-grid');
14734      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);});
14735      var cl=document.getElementById('settings-close');
14736      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);
14737      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');});
14738      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14739      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14740    }
14741    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14742  }());
14743  </script>
14744  <footer class="site-footer">
14745    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
14746    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14747    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14748    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14749    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14750  </footer>
14751  {% if confluence_configured %}
14752  <script nonce="{{ csp_nonce }}">
14753  (function() {
14754    var postBtn = document.getElementById('postConfluenceBtn');
14755    var copyBtn = document.getElementById('copyWikiBtn');
14756    var modal   = document.getElementById('confluenceModal');
14757    if (!postBtn || !modal) return;
14758
14759    postBtn.addEventListener('click', function() {
14760      document.getElementById('confStatus').style.display = 'none';
14761      modal.style.display = 'flex';
14762    });
14763    document.getElementById('confCancelBtn').addEventListener('click', function() {
14764      modal.style.display = 'none';
14765    });
14766    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
14767
14768    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
14769      var btn = this;
14770      btn.disabled = true;
14771      var status = document.getElementById('confStatus');
14772      status.style.display = 'block';
14773      status.style.background = '#dbeafe';
14774      status.style.color = '#1e40af';
14775      status.textContent = 'Posting to Confluence…';
14776      var resp = await fetch('/api/confluence/post', {
14777        method: 'POST',
14778        headers: { 'Content-Type': 'application/json' },
14779        body: JSON.stringify({
14780          run_id: '{{ run_id }}',
14781          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
14782          report_url: document.getElementById('confReportUrl').value.trim() || null
14783        })
14784      });
14785      var data = await resp.json();
14786      if (data.ok) {
14787        status.style.background = '#dcfce7'; status.style.color = '#166534';
14788        status.textContent = 'Posted! Page ID: ' + data.page_id;
14789      } else {
14790        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
14791        status.textContent = 'Error: ' + (data.error || 'Unknown error');
14792      }
14793      btn.disabled = false;
14794    });
14795
14796    if (copyBtn) {
14797      copyBtn.addEventListener('click', async function() {
14798        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
14799        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
14800        var text = await resp.text();
14801        try {
14802          await navigator.clipboard.writeText(text);
14803          var orig = copyBtn.textContent;
14804          copyBtn.textContent = 'Copied!';
14805          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
14806        } catch(e) {
14807          alert('Clipboard write failed — check browser permissions.');
14808        }
14809      });
14810    }
14811  })();
14812  </script>
14813  {% endif %}
14814  {% if let Some(banner) = report_header_footer %}
14815  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
14816  {% endif %}
14817</body>
14818</html>
14819"##,
14820    ext = "html"
14821)]
14822// Template structs need many bool fields to pass Askama rendering flags.
14823#[allow(clippy::struct_excessive_bools)]
14824struct ResultTemplate {
14825    version: &'static str,
14826    report_title: String,
14827    project_path: String,
14828    output_dir: String,
14829    run_id: String,
14830    files_analyzed: u64,
14831    files_skipped: u64,
14832    physical_lines: u64,
14833    code_lines: u64,
14834    comment_lines: u64,
14835    blank_lines: u64,
14836    mixed_lines: u64,
14837    functions: u64,
14838    classes: u64,
14839    variables: u64,
14840    imports: u64,
14841    html_url: Option<String>,
14842    pdf_url: Option<String>,
14843    json_url: Option<String>,
14844    html_download_url: Option<String>,
14845    pdf_download_url: Option<String>,
14846    json_download_url: Option<String>,
14847    html_path: Option<String>,
14848    pdf_path: Option<String>,
14849    json_path: Option<String>,
14850    prev_run_id: Option<String>,
14851    prev_run_timestamp: Option<String>,
14852    prev_run_code_lines: Option<u64>,
14853    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
14854    prev_fa_str: String,
14855    prev_fs_str: String,
14856    prev_pl_str: String,
14857    prev_cl_str: String,
14858    prev_cml_str: String,
14859    prev_bl_str: String,
14860    // Signed change column for main metrics
14861    delta_fa_str: String,
14862    delta_fa_class: String,
14863    delta_fs_str: String,
14864    delta_fs_class: String,
14865    delta_pl_str: String,
14866    delta_pl_class: String,
14867    delta_cl_str: String,
14868    delta_cl_class: String,
14869    delta_cml_str: String,
14870    delta_cml_class: String,
14871    delta_bl_str: String,
14872    delta_bl_class: String,
14873    // delta vs previous scan
14874    delta_lines_added: Option<i64>,
14875    delta_lines_removed: Option<i64>,
14876    delta_lines_net_str: String,
14877    delta_lines_net_class: String,
14878    delta_files_added: Option<usize>,
14879    delta_files_removed: Option<usize>,
14880    delta_files_modified: Option<usize>,
14881    delta_files_unchanged: Option<usize>,
14882    delta_unmodified_lines: Option<u64>,
14883    // git context
14884    git_branch: Option<String>,
14885    git_commit: Option<String>,
14886    git_author: Option<String>,
14887    // history
14888    prev_scan_count: usize,
14889    current_scan_number: usize,
14890    // submodule breakdown (empty when not requested)
14891    submodule_rows: Vec<SubmoduleRow>,
14892    scan_config_url: String,
14893    lang_chart_json: String,
14894    // Askama reads these via proc-macro expansion; clippy can't trace through it.
14895    #[allow(dead_code)]
14896    scatter_chart_json: String,
14897    #[allow(dead_code)]
14898    semantic_chart_json: String,
14899    #[allow(dead_code)]
14900    submodule_chart_json: String,
14901    #[allow(dead_code)]
14902    has_submodule_data: bool,
14903    #[allow(dead_code)]
14904    has_semantic_data: bool,
14905    pdf_generating: bool,
14906    csp_nonce: String,
14907    /// Whether Confluence integration is configured — shows Post button when true.
14908    confluence_configured: bool,
14909    /// Header/footer identification banner, mirrored from the HTML/PDF report.
14910    report_header_footer: Option<String>,
14911}
14912
14913#[derive(Template)]
14914#[template(
14915    source = r##"
14916<!doctype html>
14917<html lang="en">
14918<head>
14919  <meta charset="utf-8">
14920  <meta name="viewport" content="width=device-width, initial-scale=1">
14921  <title>OxideSLOC | Analyzing…</title>
14922  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14923  <style nonce="{{ csp_nonce }}">
14924    :root {
14925      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14926      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14927      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
14928      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14929    }
14930    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
14931    *{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);}
14932    .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);}
14933    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14934    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
14935    .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));}
14936    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14937    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
14938    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
14939    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14940    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14941    @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; } }
14942    .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;}
14943    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14944    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
14945    .page-body{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
14946    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
14947    .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;}
14948    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
14949    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
14950    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
14951    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
14952    .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;}
14953    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
14954    .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;}
14955    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
14956    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
14957    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
14958    .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;}
14959    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
14960    .hidden{display:none!important;}
14961    .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;}
14962    .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;}
14963    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
14964    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
14965    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
14966    .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);}
14967    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
14968    .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;}
14969    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
14970    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14971    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14972    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
14973    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14974    .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;}
14975    @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));}}
14976    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14977    .site-footer a{color:var(--muted);}
14978    .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;}
14979    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
14980    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
14981    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
14982  </style>
14983</head>
14984<body>
14985  <div class="background-watermarks" aria-hidden="true">
14986    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14987    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14988    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14989    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14990    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14991    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14992  </div>
14993  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14994  <nav class="top-nav">
14995    <div class="top-nav-inner">
14996      <a href="/" class="brand">
14997        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
14998        <div class="brand-copy">
14999          <h1 class="brand-title">OxideSLOC</h1>
15000          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15001        </div>
15002      </a>
15003      <div class="nav-right">
15004        <a class="nav-pill" href="/">Home</a>
15005        <div class="nav-dropdown">
15006          <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>
15007          <div class="nav-dropdown-menu">
15008            <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>
15009          </div>
15010        </div>
15011        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15012        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15013        <div class="nav-dropdown">
15014          <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>
15015          <div class="nav-dropdown-menu">
15016            <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>
15017          </div>
15018        </div>
15019        <div class="server-status-wrap">
15020          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15021          <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>
15022        </div>
15023        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15024          <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>
15025        </button>
15026        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15027          <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>
15028          <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>
15029        </button>
15030      </div>
15031    </div>
15032  </nav>
15033  <div class="page-body">
15034    <div class="wait-panel">
15035      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
15036      <h2 class="wait-title">Analyzing your project…</h2>
15037      <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
15038      <div class="path-block">{{ project_path }}</div>
15039      <div class="metrics-row">
15040        <div class="metric-card">
15041          <div class="metric-label">Elapsed</div>
15042          <div class="metric-value" id="elapsed">0s</div>
15043        </div>
15044        <div class="metric-card">
15045          <div class="metric-label">Phase</div>
15046          <div class="metric-value" id="phase">Starting</div>
15047        </div>
15048      </div>
15049      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
15050      <div class="warn-slow hidden" id="warn-slow">
15051        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.
15052      </div>
15053      <div class="err-panel hidden" id="err-panel">
15054        <strong>Analysis failed</strong>
15055        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
15056      </div>
15057      <div class="actions hidden" id="actions">
15058        <a href="/scan" class="btn-primary">Try Again</a>
15059        <a href="/view-reports" class="btn-outline">View Reports</a>
15060      </div>
15061    </div>
15062  </div>
15063  <script nonce="{{ csp_nonce }}">
15064    (function() {
15065      var WAIT_ID = {{ wait_id_json|safe }};
15066      var startTime = Date.now();
15067      var pollInterval = 1500;
15068      var retries = 0;
15069      var maxRetries = 5;
15070      var warnShown = false;
15071
15072      function elapsed() {
15073        return Math.floor((Date.now() - startTime) / 1000);
15074      }
15075
15076      function updateElapsed() {
15077        var s = elapsed();
15078        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
15079      }
15080
15081      function setPhase(txt) {
15082        document.getElementById('phase').textContent = txt;
15083      }
15084
15085      var elapsedTimer = setInterval(updateElapsed, 1000);
15086
15087      function poll() {
15088        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
15089          .then(function(r) {
15090            if (!r.ok) throw new Error('HTTP ' + r.status);
15091            return r.json();
15092          })
15093          .then(function(data) {
15094            retries = 0;
15095            if (data.state === 'complete') {
15096              clearInterval(elapsedTimer);
15097              setPhase('Done');
15098              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
15099            } else if (data.state === 'failed') {
15100              clearInterval(elapsedTimer);
15101              setPhase('Failed');
15102              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
15103              document.getElementById('err-panel').classList.remove('hidden');
15104              document.getElementById('actions').classList.remove('hidden');
15105            } else {
15106              // still running
15107              var s = elapsed();
15108              if (s > 90 && !warnShown) {
15109                warnShown = true;
15110                document.getElementById('warn-slow').classList.remove('hidden');
15111              }
15112              setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
15113              setTimeout(poll, pollInterval);
15114            }
15115          })
15116          .catch(function(err) {
15117            retries++;
15118            if (retries >= maxRetries) {
15119              clearInterval(elapsedTimer);
15120              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
15121              document.getElementById('err-panel').classList.remove('hidden');
15122              document.getElementById('actions').classList.remove('hidden');
15123            } else {
15124              // exponential back-off capped at 8s
15125              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
15126            }
15127          });
15128      }
15129
15130      setTimeout(poll, pollInterval);
15131    })();
15132  </script>
15133  <footer class="site-footer">
15134    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
15135    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15136    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15137    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15138    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
15139  </footer>
15140  <script nonce="{{ csp_nonce }}">
15141    (function(){
15142      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
15143      if(s==="dark")b.classList.add("dark-theme");
15144      var tt=document.getElementById("theme-toggle");
15145      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
15146    })();
15147    (function spawnCodeParticles(){
15148      var c=document.getElementById('code-particles');if(!c)return;
15149      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'];
15150      for(var i=0;i<32;i++){(function(idx){
15151        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
15152        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
15153        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
15154        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
15155        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
15156        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
15157        c.appendChild(el);
15158      })(i);}
15159    })();
15160    (function randomizeWatermarks(){
15161      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15162      var placed=[];
15163      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;}
15164      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];}
15165      var half=Math.floor(wms.length/2);
15166      wms.forEach(function(img,i){
15167        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
15168        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
15169        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
15170        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
15171        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
15172        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
15173      });
15174    })();
15175  </script>
15176  <script nonce="{{ csp_nonce }}">
15177  (function(){
15178    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'}];
15179    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);});}
15180    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15181    function init(){
15182      var btn=document.getElementById('settings-btn');if(!btn)return;
15183      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15184      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>';
15185      document.body.appendChild(m);
15186      var g=document.getElementById('scheme-grid');
15187      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);});
15188      var cl=document.getElementById('settings-close');
15189      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);
15190      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');});
15191      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15192      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15193    }
15194    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15195  }());
15196  </script>
15197</body>
15198</html>
15199"##,
15200    ext = "html"
15201)]
15202struct ScanWaitTemplate {
15203    version: &'static str,
15204    wait_id_json: String,
15205    project_path: String,
15206    csp_nonce: String,
15207}
15208
15209#[derive(Template)]
15210#[template(
15211    source = r##"
15212<!doctype html>
15213<html lang="en">
15214<head>
15215  <meta charset="utf-8">
15216  <meta name="viewport" content="width=device-width, initial-scale=1">
15217  <title>OxideSLOC | Error</title>
15218  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15219  <style nonce="{{ csp_nonce }}">
15220    :root {
15221      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15222      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15223      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15224      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15225    }
15226    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15227    *{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);}
15228    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15229    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15230    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15231    .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);}
15232    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15233    .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));}
15234    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15235    .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;}
15236    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15237    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15238    @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; } }
15239    .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;}
15240    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15241    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15242    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15243    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15244    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15245    .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;}
15246    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15247    .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);}
15248    .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;}
15249    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15250    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15251    .settings-modal-body{padding:14px 16px 16px;}
15252    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15253    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15254    .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;}
15255    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15256    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15257    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15258    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15259    .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;}
15260    .tz-select:focus{border-color:var(--oxide);}
15261    .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15262    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15263    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15264    .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;}
15265    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15266    .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);}
15267    .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;}
15268    .btn-secondary:hover{background:var(--line);}
15269    .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;}
15270    .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;}
15271    .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;}
15272    @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));}}
15273    .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;}
15274  </style>
15275</head>
15276<body>
15277  <div class="background-watermarks" aria-hidden="true">
15278    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15279    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15280    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15281    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15282    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15283    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15284  </div>
15285  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15286  <div class="top-nav">
15287    <div class="top-nav-inner">
15288      <a class="brand" href="/">
15289        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15290        <div class="brand-copy">
15291          <div class="brand-title">OxideSLOC</div>
15292          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15293        </div>
15294      </a>
15295      <div class="nav-right">
15296        <a class="nav-pill" href="/">Home</a>
15297        <div class="nav-dropdown">
15298          <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>
15299          <div class="nav-dropdown-menu">
15300            <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>
15301          </div>
15302        </div>
15303        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15304        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15305        <div class="nav-dropdown">
15306          <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>
15307          <div class="nav-dropdown-menu">
15308            <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>
15309          </div>
15310        </div>
15311        <div class="server-status-wrap">
15312          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15313          <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>
15314        </div>
15315        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15316          <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>
15317        </button>
15318        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15319          <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>
15320          <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>
15321        </button>
15322      </div>
15323    </div>
15324  </div>
15325
15326  <div class="page">
15327    <div class="panel">
15328      <h1>Error</h1>
15329      <div class="error-box">{{ message }}</div>
15330      <div class="actions">
15331        <a class="btn-primary" href="/scan">Back to setup</a>
15332        {% if let Some(report_url) = last_report_url %}
15333        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
15334        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
15335        {% else %}
15336        <a class="btn-secondary" href="/view-reports">View Reports</a>
15337        {% endif %}
15338      </div>
15339    </div>
15340  </div>
15341  <script nonce="{{ csp_nonce }}">
15342    (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");});})();
15343    (function spawnCodeParticles() {
15344      var container = document.getElementById('code-particles');
15345      if (!container) return;
15346      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'];
15347      for (var i = 0; i < 38; i++) {
15348        (function(idx) {
15349          var el = document.createElement('span');
15350          el.className = 'code-particle';
15351          el.textContent = snippets[idx % snippets.length];
15352          var left = Math.random() * 94 + 2;
15353          var top = Math.random() * 88 + 6;
15354          var dur = (Math.random() * 10 + 9).toFixed(1);
15355          var delay = (Math.random() * 18).toFixed(1);
15356          var rot = (Math.random() * 26 - 13).toFixed(1);
15357          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15358          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';
15359          container.appendChild(el);
15360        })(i);
15361      }
15362    })();
15363    (function randomizeWatermarks() {
15364      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15365      var placed = [];
15366      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; }
15367      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]; }
15368      var half = Math.floor(wms.length/2);
15369      wms.forEach(function(img, i) {
15370        var pos = pick(i < half);
15371        var w = Math.floor(Math.random()*60+80);
15372        var rot = (Math.random()*40-20).toFixed(1);
15373        var op = (Math.random()*0.08+0.05).toFixed(2);
15374        var animDur = (Math.random()*6+5).toFixed(1);
15375        var animDelay = (Math.random()*10).toFixed(1);
15376        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';
15377      });
15378    })();
15379  </script>
15380  <script nonce="{{ csp_nonce }}">
15381  (function(){
15382    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'}];
15383    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);});}
15384    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15385    function init(){
15386      var btn=document.getElementById('settings-btn');if(!btn)return;
15387      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15388      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>';
15389      document.body.appendChild(m);
15390      var g=document.getElementById('scheme-grid');
15391      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);});
15392      var cl=document.getElementById('settings-close');
15393      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);
15394      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');});
15395      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15396      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15397    }
15398    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15399  }());
15400  </script>
15401</body>
15402</html>
15403"##,
15404    ext = "html"
15405)]
15406struct ErrorTemplate {
15407    message: String,
15408    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
15409    last_report_url: Option<String>,
15410    /// Label for the secondary action button; defaults to "View last report" when None.
15411    last_report_label: Option<String>,
15412    csp_nonce: String,
15413}
15414
15415// ── RelocateScanTemplate ──────────────────────────────────────────────────────
15416
15417#[derive(Template)]
15418#[template(
15419    source = r##"
15420<!doctype html>
15421<html lang="en">
15422<head>
15423  <meta charset="utf-8">
15424  <meta name="viewport" content="width=device-width, initial-scale=1">
15425  <title>OxideSLOC | Locate Scan Files</title>
15426  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15427  <style nonce="{{ csp_nonce }}">
15428    :root {
15429      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15430      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15431      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15432      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15433    }
15434    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15435    *{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);}
15436    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15437    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15438    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15439    .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);}
15440    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15441    .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));}
15442    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15443    .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;}
15444    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15445    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
15446    @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;}}
15447    .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;}
15448    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15449    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15450    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15451    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15452    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15453    .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;}
15454    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15455    .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);}
15456    .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;}
15457    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15458    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15459    .settings-modal-body{padding:14px 16px 16px;}
15460    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15461    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15462    .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;}
15463    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15464    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15465    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15466    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15467    .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;}
15468    .tz-select:focus{border-color:var(--oxide);}
15469    .page{max-width:860px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15470    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15471    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15472    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
15473    .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;}
15474    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15475    .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;}
15476    .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;}
15477    .btn-secondary:hover{background:var(--line);}
15478    .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;}
15479    .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;}
15480    .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;}
15481    @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));}}
15482    .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;}
15483    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
15484    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
15485    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
15486    .relocate-row{display:flex;gap:8px;align-items:stretch;}
15487    .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;}
15488    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
15489    body.dark-theme .relocate-input{background:var(--surface-2);}
15490  </style>
15491</head>
15492<body>
15493  <div class="background-watermarks" aria-hidden="true">
15494    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15495    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15496    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15497    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15498    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15499    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15500  </div>
15501  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15502  <div class="top-nav">
15503    <div class="top-nav-inner">
15504      <a class="brand" href="/">
15505        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15506        <div class="brand-copy">
15507          <div class="brand-title">OxideSLOC</div>
15508          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15509        </div>
15510      </a>
15511      <div class="nav-right">
15512        <a class="nav-pill" href="/">Home</a>
15513        <div class="nav-dropdown">
15514          <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>
15515          <div class="nav-dropdown-menu">
15516            <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>
15517          </div>
15518        </div>
15519        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
15520        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15521        <div class="nav-dropdown">
15522          <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>
15523          <div class="nav-dropdown-menu">
15524            <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>
15525          </div>
15526        </div>
15527        <div class="server-status-wrap">
15528          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15529          <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>
15530        </div>
15531        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15532          <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>
15533        </button>
15534        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15535          <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>
15536          <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>
15537        </button>
15538      </div>
15539    </div>
15540  </div>
15541
15542  <div class="page">
15543    <div class="panel">
15544      <h1>Scan Files Moved</h1>
15545      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
15546      <div class="error-box">{{ message }}</div>
15547      <div class="relocate-section">
15548        <h2>Locate Scan Output</h2>
15549        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
15550        <form method="post" action="/relocate-scan">
15551          <input type="hidden" name="run_id" value="{{ run_id }}">
15552          <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
15553          <div class="relocate-row">
15554            <input type="text" id="relocate-folder" name="folder_path"
15555                   value="{{ folder_hint }}"
15556                   placeholder="Path to folder containing scan output..."
15557                   class="relocate-input" autocomplete="off" spellcheck="false">
15558            {% if !server_mode %}
15559            <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
15560            {% endif %}
15561          </div>
15562          <div style="margin-top:12px;">
15563            <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
15564          </div>
15565        </form>
15566      </div>
15567      <div class="actions">
15568        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
15569        <a class="btn-secondary" href="/view-reports">View Reports</a>
15570      </div>
15571    </div>
15572  </div>
15573  <script nonce="{{ csp_nonce }}">
15574    (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");});})();
15575    (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);}})();
15576    (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';});})();
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  (function(){
15599    var btn=document.getElementById('browse-relocate-btn');
15600    if(!btn)return;
15601    btn.addEventListener('click',function(){
15602      btn.disabled=true;btn.textContent='...';
15603      var inp=document.getElementById('relocate-folder');
15604      var hint=inp?inp.value:'';
15605      fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
15606        .then(function(r){return r.json();})
15607        .then(function(d){
15608          btn.disabled=false;btn.textContent='Browse…';
15609          if(d&&d.selected_path&&inp)inp.value=d.selected_path;
15610        })
15611        .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
15612    });
15613  }());
15614  </script>
15615</body>
15616</html>
15617"##,
15618    ext = "html"
15619)]
15620struct RelocateScanTemplate {
15621    message: String,
15622    run_id: String,
15623    folder_hint: String,
15624    redirect_url: String,
15625    server_mode: bool,
15626    csp_nonce: String,
15627}
15628
15629// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
15630
15631#[derive(Template)]
15632#[template(
15633    source = r##"
15634<!doctype html>
15635<html lang="en">
15636<head>
15637  <meta charset="utf-8">
15638  <meta name="viewport" content="width=device-width, initial-scale=1">
15639  <title>OxideSLOC | View Reports</title>
15640  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15641  <style nonce="{{ csp_nonce }}">
15642    :root {
15643      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
15644      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15645      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15646      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15647      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
15648    }
15649    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; }
15650    *{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);}
15651    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15652    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15653    .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);}
15654    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15655    .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));}
15656    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15657    .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;}
15658    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15659    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15660    @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; } }
15661    .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;}
15662    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15663    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15664    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15665    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15666    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15667    .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;}
15668    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15669    .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);}
15670    .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;}
15671    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15672    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15673    .settings-modal-body{padding:14px 16px 16px;}
15674    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15675    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15676    .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;}
15677    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15678    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15679    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15680    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15681    .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;}
15682    .tz-select:focus{border-color:var(--oxide);}
15683    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
15684    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
15685    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
15686    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
15687    .panel-meta{font-size:13px;color:var(--muted);}
15688    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
15689    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
15690    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
15691    .per-page-label{font-size:13px;color:var(--muted);}
15692    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;}
15693    .filter-input{min-width:180px;cursor:text;}
15694    .table-wrap{width:100%;overflow-x:auto;}
15695    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
15696    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;}
15697    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
15698    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
15699    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
15700    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
15701    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
15702    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15703    tr:last-child td{border-bottom:none;}
15704    tr:hover td{background:var(--surface-2);}
15705    .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);}
15706    .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);}
15707    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
15708    .metric-num{font-weight:700;color:var(--text);}
15709    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
15710    .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;}
15711    .btn:hover{background:var(--line);}
15712    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15713    .btn.primary:hover{opacity:.9;}
15714    .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;}
15715    .btn-back:hover{background:var(--line);}
15716    .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;}
15717    .export-btn:hover{background:var(--line);}
15718    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
15719    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
15720    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
15721    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
15722    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
15723    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
15724    .pagination-info{font-size:13px;color:var(--muted);}
15725    .pagination-btns{display:flex;gap:6px;}
15726    .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;}
15727    .pg-btn:hover:not(:disabled){background:var(--line);}
15728    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15729    .pg-btn:disabled{opacity:.35;cursor:default;}
15730    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
15731    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15732    .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;}
15733    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
15734    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
15735    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
15736    .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);}
15737    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
15738    .stat-chip:hover .stat-chip-tip{opacity:1;}
15739    .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;}
15740    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15741    .site-footer a{color:var(--muted);}
15742    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
15743    .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%;}
15744    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
15745    .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;}
15746    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15747    .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;}
15748    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15749    .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;}
15750    .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;}
15751    .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;}
15752    @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));}}
15753    .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;}
15754    .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;}
15755    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
15756    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
15757    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
15758    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
15759    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
15760    .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;}
15761    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15762    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
15763    .watched-chip-rm:hover{color:var(--oxide);}
15764    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
15765    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
15766    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
15767    .rpt-btn{min-width:58px;justify-content:center;}
15768    .flex-row{display:flex;align-items:center;gap:8px;}
15769    .report-cell{overflow:visible;white-space:normal;}
15770    #history-table col:nth-child(1){width:185px;}
15771    #history-table col:nth-child(2){width:220px;}
15772    #history-table col:nth-child(3){width:100px;}
15773    #history-table col:nth-child(4){width:72px;}
15774    #history-table col:nth-child(5){width:82px;}
15775    #history-table col:nth-child(6){width:82px;}
15776    #history-table col:nth-child(7){width:65px;}
15777    #history-table col:nth-child(8){width:90px;}
15778    #history-table col:nth-child(9){width:85px;}
15779    #history-table col:nth-child(10){width:115px;}
15780    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
15781    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
15782    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
15783    .submod-details summary::-webkit-details-marker{display:none;}
15784.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
15785    .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;}
15786    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
15787    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
15788  </style>
15789</head>
15790<body>
15791  <div class="background-watermarks" aria-hidden="true">
15792    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15793    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15794    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15795    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15796    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15797    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15798  </div>
15799  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15800  <div class="top-nav">
15801    <div class="top-nav-inner">
15802      <a class="brand" href="/">
15803        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15804        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
15805      </a>
15806      <div class="nav-right">
15807        <a class="nav-pill" href="/">Home</a>
15808        <div class="nav-dropdown">
15809          <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>
15810          <div class="nav-dropdown-menu">
15811            <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>
15812          </div>
15813        </div>
15814        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15815        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15816        <div class="nav-dropdown">
15817          <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>
15818          <div class="nav-dropdown-menu">
15819            <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>
15820          </div>
15821        </div>
15822        <div class="server-status-wrap">
15823          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15824          <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>
15825        </div>
15826        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15827          <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>
15828        </button>
15829        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15830          <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>
15831          <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>
15832        </button>
15833      </div>
15834    </div>
15835  </div>
15836
15837  <div class="page">
15838    {% if let Some(err) = browse_error %}
15839    <div class="toast-error">
15840      <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>
15841      {{ err }}
15842    </div>
15843    {% endif %}
15844    {% if linked_count > 0 %}
15845    <div class="toast-success">
15846      <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>
15847      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
15848    </div>
15849    {% endif %}
15850    <div class="watched-bar">
15851      <div class="watched-bar-left">
15852        <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>
15853        <span class="watched-label">Watched Folders</span>
15854        <div class="watched-chips">
15855          {% for dir in watched_dirs %}
15856          <span class="watched-chip">
15857            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
15858            <form method="POST" action="/watched-dirs/remove" style="display:contents">
15859              <input type="hidden" name="folder_path" value="{{ dir }}">
15860              <input type="hidden" name="redirect_to" value="/view-reports">
15861              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
15862            </form>
15863          </span>
15864          {% endfor %}
15865          {% if watched_dirs.is_empty() %}
15866          <span class="watched-none">No folders watched — click Choose to add one</span>
15867          {% endif %}
15868        </div>
15869      </div>
15870      <div class="watched-bar-right">
15871        <button type="button" class="btn" id="add-watched-btn">
15872          <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>
15873          Choose
15874        </button>
15875        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
15876          <input type="hidden" name="redirect_to" value="/view-reports">
15877          <button type="submit" class="btn">&#8635; Refresh</button>
15878        </form>
15879      </div>
15880    </div>
15881    {% if total_scans > 0 %}
15882    <div class="summary-strip">
15883      <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>
15884      <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>
15885      <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>
15886      <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>
15887    </div>
15888    {% endif %}
15889
15890    <section class="panel">
15891      <div class="panel-header">
15892        <div>
15893          <h1>View Reports</h1>
15894          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
15895        </div>
15896        <div class="flex-row">
15897          <button type="button" class="export-btn" id="export-csv-btn">
15898            <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>
15899            Export CSV
15900          </button>
15901          <button type="button" class="export-btn" id="export-xls-btn">
15902            <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>
15903            Export Excel
15904          </button>
15905        </div>
15906      </div>
15907
15908      {% if entries.is_empty() %}
15909      <div class="empty-state">
15910        <strong>No reports with viewable HTML yet</strong>
15911        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.
15912      </div>
15913      {% else %}
15914      <div class="filter-row">
15915        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
15916        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
15917        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
15918      </div>
15919      <div class="table-wrap">
15920        <table id="history-table">
15921          <colgroup>
15922            <col><col><col><col><col><col><col><col><col><col>
15923          </colgroup>
15924          <thead>
15925            <tr id="history-thead">
15926              <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>
15927              <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>
15928              <th>Run ID<div class="col-resize-handle"></div></th>
15929              <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>
15930              <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>
15931              <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>
15932              <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>
15933              <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>
15934              <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>
15935              <th>Report<div class="col-resize-handle"></div></th>
15936            </tr>
15937          </thead>
15938          <tbody id="history-tbody">
15939            {% for entry in entries %}
15940            <tr class="history-row" data-run="{{ entry.run_id }}"
15941                data-timestamp="{{ entry.timestamp }}"
15942                data-project="{{ entry.project_label }}"
15943                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
15944                data-skipped="{{ entry.files_skipped }}"
15945                data-comments="{{ entry.comment_lines }}"
15946                data-blank="{{ entry.blank_lines }}"
15947                data-branch="{{ entry.git_branch }}"
15948                data-commit="{{ entry.git_commit }}"
15949                data-html-url="/runs/html/{{ entry.run_id }}">
15950              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
15951              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
15952              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
15953              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
15954              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
15955              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
15956              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
15957              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
15958              <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>
15959              <td class="report-cell">
15960                <div class="actions-cell">
15961                  {% 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 %}
15962                  {% 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 %}
15963                </div>
15964                {% if !entry.submodule_links.is_empty() %}
15965                <details class="submod-details">
15966                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
15967                  <div class="submod-link-list">
15968                    {% for sub in entry.submodule_links %}
15969                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
15970                    {% endfor %}
15971                  </div>
15972                </details>
15973                {% endif %}
15974              </td>
15975            </tr>
15976            {% endfor %}
15977          </tbody>
15978        </table>
15979      </div>
15980      <div class="pagination">
15981        <span class="pagination-info" id="pagination-info"></span>
15982        <div class="pagination-btns" id="pagination-btns"></div>
15983        <div class="flex-row">
15984          <span class="per-page-label">Show</span>
15985          <select class="per-page" id="per-page-sel">
15986            <option value="10">10 per page</option>
15987            <option value="25" selected>25 per page</option>
15988            <option value="50">50 per page</option>
15989            <option value="100">100 per page</option>
15990          </select>
15991          <span class="per-page-label" id="page-range-label"></span>
15992        </div>
15993      </div>
15994      {% endif %}
15995    </section>
15996  </div>
15997
15998  <footer class="site-footer">
15999    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
16000    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16001    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16002    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16003    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16004  </footer>
16005
16006  <script nonce="{{ csp_nonce }}">
16007    (function () {
16008      // ── Theme ──────────────────────────────────────────────────────────────
16009      var storageKey = 'oxide-sloc-theme';
16010      var body = document.body;
16011      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16012      var toggle = document.getElementById('theme-toggle');
16013      if (toggle) toggle.addEventListener('click', function () {
16014        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16015        body.classList.toggle('dark-theme', next === 'dark');
16016        try { localStorage.setItem(storageKey, next); } catch(e) {}
16017      });
16018
16019      // ── State ─────────────────────────────────────────────────────────────
16020      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
16021      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
16022      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16023
16024      // Aggregate stats from first (most recent) row
16025      if (allRows.length) {
16026        var first = allRows[0];
16027        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();}
16028        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>':'');}
16029        setChipVal('agg-code', first.dataset.code);
16030        setChipVal('agg-files', first.dataset.files);
16031        setChipVal('agg-skipped', first.dataset.skipped);
16032      }
16033
16034      // ── Branch filter population ──────────────────────────────────────────
16035      (function() {
16036        var branches = {};
16037        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16038        var sel = document.getElementById('branch-filter');
16039        if (sel) Object.keys(branches).sort().forEach(function(b) {
16040          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16041        });
16042      })();
16043
16044      // ── Filter ────────────────────────────────────────────────────────────
16045      function getFilteredRows() {
16046        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16047        var branch = ((document.getElementById('branch-filter') || {}).value || '');
16048        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
16049          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16050          if (branch && (r.dataset.branch || '') !== branch) return false;
16051          return true;
16052        });
16053      }
16054
16055      // ── Pagination ────────────────────────────────────────────────────────
16056      function renderPage() {
16057        var filtered = getFilteredRows();
16058        var total = filtered.length;
16059        var totalPages = Math.max(1, Math.ceil(total / perPage));
16060        currentPage = Math.min(currentPage, totalPages);
16061        var start = (currentPage - 1) * perPage;
16062        var end = Math.min(start + perPage, total);
16063        var shown = {};
16064        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16065        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
16066          r.style.display = shown[r.dataset.run] ? '' : 'none';
16067        });
16068        var rl = document.getElementById('page-range-label');
16069        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16070        var info = document.getElementById('pagination-info');
16071        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16072        var btns = document.getElementById('pagination-btns');
16073        if (!btns) return;
16074        btns.innerHTML = '';
16075        function makeBtn(lbl, pg, active, disabled) {
16076          var b = document.createElement('button');
16077          b.className = 'pg-btn' + (active ? ' active' : '');
16078          b.textContent = lbl; b.disabled = disabled;
16079          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16080          return b;
16081        }
16082        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16083        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16084        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16085        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16086      }
16087
16088      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16089      window.applyFilters = function() { currentPage = 1; renderPage(); };
16090
16091      // ── Sorting ───────────────────────────────────────────────────────────
16092      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
16093      function doSort(col, type, order) {
16094        var tbody = document.getElementById('history-tbody');
16095        if (!tbody) return;
16096        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16097        rows.sort(function(a, b) {
16098          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16099          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16100          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16101          return va < vb ? 1 : va > vb ? -1 : 0;
16102        });
16103        rows.forEach(function(r) { tbody.appendChild(r); });
16104        currentPage = 1; renderPage();
16105      }
16106      sortHeaders.forEach(function(th) {
16107        th.addEventListener('click', function(e) {
16108          if (e.target.classList.contains('col-resize-handle')) return;
16109          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16110          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16111          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16112          th.classList.add('sort-' + sortOrder);
16113          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16114          doSort(col, type, sortOrder);
16115        });
16116      });
16117
16118      // ── Column resize ─────────────────────────────────────────────────────
16119      (function() {
16120        var table = document.getElementById('history-table');
16121        if (!table) return;
16122        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16123        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
16124        ths.forEach(function(th, i) {
16125          var handle = th.querySelector('.col-resize-handle');
16126          if (!handle || !cols[i]) return;
16127          var startX, startW;
16128          handle.addEventListener('mousedown', function(e) {
16129            e.stopPropagation(); e.preventDefault();
16130            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16131            handle.classList.add('dragging');
16132            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16133            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16134            document.addEventListener('mousemove', onMove);
16135            document.addEventListener('mouseup', onUp);
16136          });
16137        });
16138      })();
16139
16140      // ── Reset view ────────────────────────────────────────────────────────
16141      window.resetView = function() {
16142        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16143        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16144        sortCol = null; sortOrder = 'asc';
16145        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16146        var tbody = document.getElementById('history-tbody');
16147        if (tbody) {
16148          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16149          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16150          rows.forEach(function(r) { tbody.appendChild(r); });
16151        }
16152        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16153        var table = document.getElementById('history-table');
16154        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
16155        currentPage = 1; renderPage();
16156      };
16157
16158      renderPage();
16159
16160      // ── Export helpers ────────────────────────────────────────────────────
16161      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
16162      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
16163      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);}
16164      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;');}
16165      function slocXlsx(fname,sheet,hdrs,rows){
16166        var enc=new TextEncoder();
16167        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;}
16168        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;}
16169        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
16170        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
16171        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16172        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;}
16173        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];}
16174        var rx='<row r="1">';
16175        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
16176        rx+='</row>';
16177        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>';});
16178        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
16179        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>';
16180        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>';
16181        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>';
16182        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>',
16183          '_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>',
16184          '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>',
16185          '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>',
16186          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
16187        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'];
16188        var zparts=[],zcds=[],zoff=0,znf=0;
16189        order.forEach(function(name){
16190          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
16191          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]);
16192          var entry=new Uint8Array(lha.length+nb.length+sz);
16193          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
16194          zparts.push(entry);
16195          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));
16196          var cde=new Uint8Array(cda.length+nb.length);
16197          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
16198          zcds.push(cde);zoff+=entry.length;znf++;
16199        });
16200        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
16201        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]);
16202        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
16203        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
16204        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
16205        zout.set(new Uint8Array(ea),zpos);
16206        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
16207      }
16208
16209      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
16210      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;}
16211      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
16212      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
16213
16214      var csvBtn = document.getElementById('export-csv-btn');
16215      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
16216      var xlsBtn = document.getElementById('export-xls-btn');
16217      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
16218
16219      // ── Remaining CSP-safe event bindings ────────────────────────────────
16220      (function wireEvents() {
16221        var el;
16222        el = document.getElementById('reset-view-btn');
16223        if (el) el.addEventListener('click', window.resetView);
16224        el = document.getElementById('project-filter');
16225        if (el) el.addEventListener('input', window.applyFilters);
16226        el = document.getElementById('branch-filter');
16227        if (el) el.addEventListener('change', window.applyFilters);
16228        el = document.getElementById('per-page-sel');
16229        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
16230        el = document.getElementById('add-watched-btn');
16231        if (el) el.addEventListener('click', function() {
16232          fetch('/pick-directory?kind=reports')
16233            .then(function(r) { return r.json(); })
16234            .then(function(data) {
16235              if (!data.cancelled && data.selected_path) {
16236                var form = document.createElement('form');
16237                form.method = 'POST';
16238                form.action = '/watched-dirs/add';
16239                var ri = document.createElement('input');
16240                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
16241                var fi = document.createElement('input');
16242                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
16243                form.appendChild(ri); form.appendChild(fi);
16244                document.body.appendChild(form);
16245                form.submit();
16246              }
16247            })
16248            .catch(function(e) { alert('Could not open folder picker: ' + e); });
16249        });
16250      })();
16251
16252      (function randomizeWatermarks() {
16253        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16254        if (!wms.length) return;
16255        var placed = [];
16256        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;}
16257        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];}
16258        var half=Math.floor(wms.length/2);
16259        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;});
16260      })();
16261
16262      (function spawnCodeParticles() {
16263        var container = document.getElementById('code-particles');
16264        if (!container) return;
16265        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'];
16266        for (var i = 0; i < 38; i++) {
16267          (function(idx) {
16268            var el = document.createElement('span');
16269            el.className = 'code-particle';
16270            el.textContent = snippets[idx % snippets.length];
16271            var left = Math.random() * 94 + 2;
16272            var top = Math.random() * 88 + 6;
16273            var dur = (Math.random() * 10 + 9).toFixed(1);
16274            var delay = (Math.random() * 18).toFixed(1);
16275            var rot = (Math.random() * 26 - 13).toFixed(1);
16276            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16277            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';
16278            container.appendChild(el);
16279          })(i);
16280        }
16281      })();
16282    })();
16283  </script>
16284  <script nonce="{{ csp_nonce }}">
16285  (function(){
16286    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'}];
16287    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);});}
16288    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16289    function init(){
16290      var btn=document.getElementById('settings-btn');if(!btn)return;
16291      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16292      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>';
16293      document.body.appendChild(m);
16294      var g=document.getElementById('scheme-grid');
16295      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);});
16296      var cl=document.getElementById('settings-close');
16297      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);
16298      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');});
16299      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16300      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16301    }
16302    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16303  }());
16304  </script>
16305</body>
16306</html>
16307"##,
16308    ext = "html"
16309)]
16310struct HistoryTemplate {
16311    version: &'static str,
16312    entries: Vec<HistoryEntryRow>,
16313    total_scans: usize,
16314    linked_count: usize,
16315    browse_error: Option<String>,
16316    watched_dirs: Vec<String>,
16317    csp_nonce: String,
16318}
16319
16320// ── CompareSelectTemplate ──────────────────────────────────────────────────────
16321
16322#[derive(Template)]
16323#[template(
16324    source = r##"
16325<!doctype html>
16326<html lang="en">
16327<head>
16328  <meta charset="utf-8">
16329  <meta name="viewport" content="width=device-width, initial-scale=1">
16330  <title>OxideSLOC | Compare Scans</title>
16331  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16332  <style nonce="{{ csp_nonce }}">
16333    :root {
16334      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
16335      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16336      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16337      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16338      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
16339    }
16340    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
16341    *{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);}
16342    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16343    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16344    .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);}
16345    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16346    .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));}
16347    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16348    .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;}
16349    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16350    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16351    @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; } }
16352    .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;}
16353    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16354    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
16355    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16356    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16357    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16358    .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;}
16359    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16360    .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);}
16361    .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;}
16362    .settings-close:hover{color:var(--text);background:var(--surface-2);}
16363    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16364    .settings-modal-body{padding:14px 16px 16px;}
16365    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16366    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16367    .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;}
16368    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16369    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16370    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16371    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16372    .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;}
16373    .tz-select:focus{border-color:var(--oxide);}
16374    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
16375    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
16376    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
16377    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
16378    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
16379    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
16380    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
16381    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
16382    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
16383    .per-page-label{font-size:13px;color:var(--muted);}
16384    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;}
16385    .filter-input{min-width:180px;cursor:text;}
16386    .table-wrap{width:100%;overflow-x:auto;}
16387    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
16388    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;}
16389    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
16390    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
16391    #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;}
16392    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
16393    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
16394    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
16395    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
16396    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
16397    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
16398    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
16399    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
16400    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
16401    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
16402    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
16403    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
16404    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16405    tr:last-child td{border-bottom:none;}
16406    tr.selected td{background:var(--sel-bg);}
16407    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
16408    tr:hover:not(.selected) td{background:var(--surface-2);}
16409    tr{cursor:pointer;}
16410    .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);}
16411    .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);}
16412    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
16413    .metric-num{font-weight:700;color:var(--text);}
16414    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
16415    .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;}
16416    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
16417    .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;}
16418    .btn:hover{background:var(--line);}
16419    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
16420    .btn.primary:hover{opacity:.9;}
16421    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
16422    .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;}
16423    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
16424    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
16425    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
16426    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
16427    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
16428    .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;}
16429    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16430    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
16431    .watched-chip-rm:hover{color:var(--oxide);}
16432    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
16433    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
16434    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
16435    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
16436    .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;}
16437    .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;}
16438    .btn-back:hover{background:var(--line);}
16439    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
16440    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
16441    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
16442    .pagination-info{font-size:13px;color:var(--muted);}
16443    .pagination-btns{display:flex;gap:6px;}
16444    .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;}
16445    .pg-btn:hover:not(:disabled){background:var(--line);}
16446    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
16447    .pg-btn:disabled{opacity:.35;cursor:default;}
16448    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
16449    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16450    .site-footer a{color:var(--muted);}
16451    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
16452    .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;}
16453    .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;}
16454    .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;}
16455    @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));}}
16456    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
16457    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
16458    .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;}
16459    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
16460    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
16461    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
16462    .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);}
16463    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
16464    .stat-chip:hover .stat-chip-tip{opacity:1;}
16465    .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;}
16466    .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;}
16467    .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%;}
16468    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
16469    .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;}
16470    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
16471    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
16472    .hidden{display:none!important;}
16473    .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%;}
16474    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
16475    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
16476    .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;}
16477    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
16478    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
16479    .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;}
16480    .scope-option:hover{background:var(--line);}
16481    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
16482    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
16483    .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;}
16484    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
16485    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
16486    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
16487    .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;}
16488  </style>
16489</head>
16490<body>
16491  <div class="background-watermarks" aria-hidden="true">
16492    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16493    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16494    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16495    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16496    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16497    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16498  </div>
16499  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16500  <div class="top-nav">
16501    <div class="top-nav-inner">
16502      <a class="brand" href="/">
16503        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16504        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
16505      </a>
16506      <div class="nav-right">
16507        <a class="nav-pill" href="/">Home</a>
16508        <div class="nav-dropdown">
16509          <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>
16510          <div class="nav-dropdown-menu">
16511            <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>
16512          </div>
16513        </div>
16514        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16515        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16516        <div class="nav-dropdown">
16517          <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>
16518          <div class="nav-dropdown-menu">
16519            <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>
16520          </div>
16521        </div>
16522        <div class="server-status-wrap">
16523          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
16524          <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>
16525        </div>
16526        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16527          <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>
16528        </button>
16529        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16530          <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>
16531          <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>
16532        </button>
16533      </div>
16534    </div>
16535  </div>
16536
16537  <div class="page">
16538    <div class="watched-bar">
16539      <div class="watched-bar-left">
16540        <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>
16541        <span class="watched-label">Watched Folders</span>
16542        <div class="watched-chips">
16543          {% for dir in watched_dirs %}
16544          <span class="watched-chip">
16545            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
16546            <form method="POST" action="/watched-dirs/remove" style="display:contents">
16547              <input type="hidden" name="folder_path" value="{{ dir }}">
16548              <input type="hidden" name="redirect_to" value="/compare-scans">
16549              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
16550            </form>
16551          </span>
16552          {% endfor %}
16553          {% if watched_dirs.is_empty() %}
16554          <span class="watched-none">No folders watched — click Choose to add one</span>
16555          {% endif %}
16556        </div>
16557      </div>
16558      <div class="watched-bar-right">
16559        <button type="button" class="btn" id="add-watched-btn">
16560          <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>
16561          Choose
16562        </button>
16563        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
16564          <input type="hidden" name="redirect_to" value="/compare-scans">
16565          <button type="submit" class="btn">&#8635; Refresh</button>
16566        </form>
16567      </div>
16568    </div>
16569    {% if total_scans > 0 %}
16570    <div class="summary-strip">
16571      <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>
16572      <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>
16573      <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>
16574      <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>
16575    </div>
16576    {% endif %}
16577    <section class="panel">
16578      <div class="panel-header">
16579        <div>
16580          <h1>Compare Scans</h1>
16581          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
16582        </div>
16583        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
16584          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
16585            <button class="btn primary" id="compare-btn" disabled>
16586              <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>
16587              Compare <span class="sel-count" id="sel-count">0/2</span>
16588            </button>
16589          </div>
16590        </div>
16591      </div>
16592
16593      {% if entries.is_empty() %}
16594      <div class="empty-state">
16595        <strong>No scans yet</strong>
16596        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.
16597      </div>
16598      {% else %}
16599      <div class="filter-row">
16600        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
16601        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
16602        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
16603      </div>
16604      <div class="scope-panel hidden" id="scope-panel">
16605        <div class="scope-panel-label">
16606          <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>
16607          Compare scope — choose what to include
16608        </div>
16609        <div class="scope-options" id="scope-options"></div>
16610      </div>
16611      {% if total_scans > 0 %}
16612      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
16613        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
16614          <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>
16615          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
16616        </div>
16617      </div>
16618      {% endif %}
16619      <div class="table-wrap">
16620        <table id="compare-table">
16621          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
16622          <thead>
16623            <tr id="compare-thead">
16624              <th><div class="col-resize-handle"></div></th>
16625              <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>
16626              <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>
16627              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
16628              <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>
16629              <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>
16630              <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>
16631              <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>
16632              <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>
16633              <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>
16634              <th>Submodules<div class="col-resize-handle"></div></th>
16635            </tr>
16636          </thead>
16637          <tbody id="compare-tbody">
16638            {% for entry in entries %}
16639            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
16640                data-timestamp="{{ entry.timestamp }}"
16641                data-project="{{ entry.project_label }}"
16642                data-files="{{ entry.files_analyzed }}"
16643                data-code="{{ entry.code_lines }}"
16644                data-comments="{{ entry.comment_lines }}"
16645                data-blank="{{ entry.blank_lines }}"
16646                data-branch="{{ entry.git_branch }}"
16647                data-commit="{{ entry.git_commit }}"
16648                data-submodules="{{ entry.submodule_names_csv }}">
16649              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
16650              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
16651              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
16652              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
16653              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
16654              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
16655              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
16656              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
16657              <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>
16658              <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>
16659              <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>
16660            </tr>
16661            {% endfor %}
16662          </tbody>
16663        </table>
16664      </div>
16665      <div class="pagination">
16666        <span class="pagination-info" id="pagination-info"></span>
16667        <div class="pagination-btns" id="pagination-btns"></div>
16668        <div class="flex-row">
16669          <span class="per-page-label">Show</span>
16670          <select class="per-page" id="per-page-sel">
16671            <option value="10">10 per page</option>
16672            <option value="25" selected>25 per page</option>
16673            <option value="50">50 per page</option>
16674            <option value="100">100 per page</option>
16675          </select>
16676          <span class="per-page-label" id="page-range-label"></span>
16677        </div>
16678      </div>
16679      {% endif %}
16680    </section>
16681  </div>
16682
16683  <footer class="site-footer">
16684    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
16685    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16686    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16687    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16688    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16689  </footer>
16690
16691  <script nonce="{{ csp_nonce }}">
16692    (function () {
16693      // ── Theme ──────────────────────────────────────────────────────────────
16694      var storageKey = 'oxide-sloc-theme';
16695      var body = document.body;
16696      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16697      var toggle = document.getElementById('theme-toggle');
16698      if (toggle) toggle.addEventListener('click', function () {
16699        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16700        body.classList.toggle('dark-theme', next === 'dark');
16701        try { localStorage.setItem(storageKey, next); } catch(e) {}
16702      });
16703
16704      // ── State ─────────────────────────────────────────────────────────────
16705      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
16706      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
16707      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16708
16709      // ── Stat chips ────────────────────────────────────────────────────────
16710      (function() {
16711        var projects = {}, latestTs = '', latestRow = null;
16712        allRows.forEach(function(r) {
16713          var p = r.dataset.project || ''; if (p) projects[p] = true;
16714          var ts = r.dataset.timestamp || '';
16715          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
16716        });
16717        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();}
16718        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>':'');}
16719        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
16720        if (latestRow) {
16721          setChipVal('agg-code', latestRow.dataset.code);
16722          setChipVal('agg-files', latestRow.dataset.files);
16723        }
16724      })();
16725
16726      // ── Branch filter population ──────────────────────────────────────────
16727      (function() {
16728        var branches = {};
16729        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16730        var sel = document.getElementById('branch-filter');
16731        if (sel) Object.keys(branches).sort().forEach(function(b) {
16732          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16733        });
16734      })();
16735
16736      // ── Filter ────────────────────────────────────────────────────────────
16737      function getFilteredRows() {
16738        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16739        var branch = ((document.getElementById('branch-filter') || {}).value || '');
16740        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
16741          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16742          if (branch && (r.dataset.branch || '') !== branch) return false;
16743          return true;
16744        });
16745      }
16746
16747      // ── Pagination ────────────────────────────────────────────────────────
16748      function renderPage() {
16749        var filtered = getFilteredRows();
16750        var total = filtered.length;
16751        var totalPages = Math.max(1, Math.ceil(total / perPage));
16752        currentPage = Math.min(currentPage, totalPages);
16753        var start = (currentPage - 1) * perPage;
16754        var end = Math.min(start + perPage, total);
16755        var shown = {};
16756        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16757        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
16758          r.style.display = shown[r.dataset.run] ? '' : 'none';
16759        });
16760        var rl = document.getElementById('page-range-label');
16761        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16762        var info = document.getElementById('pagination-info');
16763        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16764        var btns = document.getElementById('pagination-btns');
16765        if (!btns) return;
16766        btns.innerHTML = '';
16767        function makeBtn(lbl, pg, active, disabled) {
16768          var b = document.createElement('button');
16769          b.className = 'pg-btn' + (active ? ' active' : '');
16770          b.textContent = lbl; b.disabled = disabled;
16771          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16772          return b;
16773        }
16774        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16775        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16776        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16777        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16778      }
16779
16780      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16781      window.applyFilters = function() { currentPage = 1; renderPage(); };
16782
16783      // ── Sorting ───────────────────────────────────────────────────────────
16784      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
16785      function doSort(col, type, order) {
16786        var tbody = document.getElementById('compare-tbody');
16787        if (!tbody) return;
16788        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16789        rows.sort(function(a, b) {
16790          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16791          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16792          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16793          return va < vb ? 1 : va > vb ? -1 : 0;
16794        });
16795        rows.forEach(function(r) { tbody.appendChild(r); });
16796        currentPage = 1; renderPage();
16797      }
16798      sortHeaders.forEach(function(th) {
16799        th.addEventListener('click', function(e) {
16800          if (e.target.classList.contains('col-resize-handle')) return;
16801          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16802          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16803          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16804          th.classList.add('sort-' + sortOrder);
16805          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16806          doSort(col, type, sortOrder);
16807        });
16808      });
16809
16810      // Apply default sort (timestamp desc) on initial load
16811      (function() {
16812        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
16813        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
16814      })();
16815
16816      // ── Column resize ─────────────────────────────────────────────────────
16817      (function() {
16818        var table = document.getElementById('compare-table');
16819        if (!table) return;
16820        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16821        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
16822        ths.forEach(function(th, i) {
16823          var handle = th.querySelector('.col-resize-handle');
16824          if (!handle || !cols[i]) return;
16825          var startX, startW;
16826          handle.addEventListener('mousedown', function(e) {
16827            e.stopPropagation(); e.preventDefault();
16828            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16829            handle.classList.add('dragging');
16830            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16831            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16832            document.addEventListener('mousemove', onMove);
16833            document.addEventListener('mouseup', onUp);
16834          });
16835        });
16836      })();
16837
16838      // ── Reset view ────────────────────────────────────────────────────────
16839      window.resetView = function() {
16840        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16841        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16842        sortCol = null; sortOrder = 'asc';
16843        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16844        var tbody = document.getElementById('compare-tbody');
16845        if (tbody) {
16846          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16847          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16848          rows.forEach(function(r) { tbody.appendChild(r); });
16849        }
16850        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16851        var table = document.getElementById('compare-table');
16852        currentPage = 1; renderPage();
16853        currentPage = 1; renderPage();
16854      };
16855
16856      renderPage();
16857
16858      // ── Row selection state ───────────────────────────────────────────────
16859      var selected = [];
16860      function updateCompareBtn() {
16861        var btn = document.getElementById('compare-btn');
16862        var cnt = document.getElementById('sel-count');
16863        if (!btn) return;
16864        btn.disabled = selected.length !== 2;
16865        if (cnt) cnt.textContent = selected.length + '/2';
16866      }
16867
16868      function toggleRow(row) {
16869        var vid = row.dataset.vid || row.dataset.run;
16870        var idx = selected.indexOf(vid);
16871        if (idx >= 0) {
16872          selected.splice(idx, 1);
16873          row.classList.remove('selected');
16874          var b = document.getElementById('badge-' + vid);
16875          if (b) b.textContent = '';
16876        } else {
16877          if (selected.length >= 2) return;
16878          selected.push(vid);
16879          row.classList.add('selected');
16880        }
16881        selected.forEach(function(v, i) {
16882          var b = document.getElementById('badge-' + v);
16883          if (b) b.textContent = i + 1;
16884        });
16885        updateCompareBtn();
16886        buildScopePanel();
16887      }
16888
16889      // ── Scope panel ───────────────────────────────────────────────────────
16890      var selectedScope = 'all';
16891
16892      function buildScopePanel() {
16893        var panel = document.getElementById('scope-panel');
16894        var opts = document.getElementById('scope-options');
16895        if (!panel || !opts) return;
16896        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
16897
16898        // Collect union of submodules from both selected rows.
16899        var allSubs = {};
16900        selected.forEach(function(vid) {
16901          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
16902          if (!row) return;
16903          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
16904        });
16905        var subList = Object.keys(allSubs).sort();
16906        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
16907
16908        panel.classList.remove('hidden');
16909        opts.innerHTML = '';
16910
16911        function makeOption(value, label, title) {
16912          var div = document.createElement('div');
16913          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
16914          div.dataset.scopeValue = value;
16915          if (title) div.title = title;
16916          var radio = document.createElement('span');
16917          radio.className = 'scope-option-radio';
16918          var lbl = document.createElement('span');
16919          lbl.textContent = label;
16920          div.appendChild(radio);
16921          div.appendChild(lbl);
16922          div.addEventListener('click', function() {
16923            selectedScope = value;
16924            opts.querySelectorAll('.scope-option').forEach(function(o) {
16925              o.classList.toggle('selected', o.dataset.scopeValue === value);
16926            });
16927          });
16928          return div;
16929        }
16930
16931        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
16932        var sep = document.createElement('span');
16933        sep.className = 'scope-option-sep';
16934        opts.appendChild(sep);
16935        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
16936        subList.forEach(function(s) {
16937          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
16938        });
16939      }
16940
16941      function doCompare() {
16942        if (selected.length !== 2) return;
16943        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
16944        if (selectedScope === 'super') url += '&scope=super';
16945        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
16946        window.location.href = url;
16947      }
16948
16949      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
16950      var cbtn = document.getElementById('compare-btn');
16951      if (cbtn) cbtn.addEventListener('click', doCompare);
16952      var pfEl = document.getElementById('project-filter');
16953      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
16954      var bfEl = document.getElementById('branch-filter');
16955      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
16956      var rvBtn = document.getElementById('reset-view-btn');
16957      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
16958      var ppSel = document.getElementById('per-page-sel');
16959      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
16960
16961      var cmpTbody = document.getElementById('compare-tbody');
16962      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
16963        var row = e.target.closest('.compare-row');
16964        if (row) toggleRow(row);
16965      });
16966
16967      (function randomizeWatermarks() {
16968        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16969        if (!wms.length) return;
16970        var placed = [];
16971        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;}
16972        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];}
16973        var half=Math.floor(wms.length/2);
16974        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;});
16975      })();
16976
16977      (function spawnCodeParticles() {
16978        var container = document.getElementById('code-particles');
16979        if (!container) return;
16980        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'];
16981        for (var i = 0; i < 38; i++) {
16982          (function(idx) {
16983            var el = document.createElement('span');
16984            el.className = 'code-particle';
16985            el.textContent = snippets[idx % snippets.length];
16986            var left = Math.random() * 94 + 2;
16987            var top = Math.random() * 88 + 6;
16988            var dur = (Math.random() * 10 + 9).toFixed(1);
16989            var delay = (Math.random() * 18).toFixed(1);
16990            var rot = (Math.random() * 26 - 13).toFixed(1);
16991            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16992            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';
16993            container.appendChild(el);
16994          })(i);
16995        }
16996      })();
16997
16998      // ── Watched folder picker ─────────────────────────────────────────────
16999      (function() {
17000        var btn = document.getElementById('add-watched-btn');
17001        if (!btn) return;
17002        btn.addEventListener('click', function() {
17003          fetch('/pick-directory?kind=reports')
17004            .then(function(r) { return r.json(); })
17005            .then(function(data) {
17006              if (!data.cancelled && data.selected_path) {
17007                var form = document.createElement('form');
17008                form.method = 'POST';
17009                form.action = '/watched-dirs/add';
17010                var ri = document.createElement('input');
17011                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
17012                var fi = document.createElement('input');
17013                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
17014                form.appendChild(ri); form.appendChild(fi);
17015                document.body.appendChild(form);
17016                form.submit();
17017              }
17018            })
17019            .catch(function(e) { alert('Could not open folder picker: ' + e); });
17020        });
17021      })();
17022
17023      // ── Submodule chip truncation ─────────────────────────────────────────
17024      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
17025        var chips = cell.querySelectorAll('.submod-chip');
17026        var MAX = 4;
17027        if (chips.length <= MAX) return;
17028        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
17029        var badge = document.createElement('span');
17030        badge.className = 'submod-overflow-badge';
17031        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
17032        badge.textContent = '+' + (chips.length - MAX) + ' more';
17033        cell.appendChild(badge);
17034        cell.style.maxHeight = 'none';
17035      });
17036    })();
17037  </script>
17038  <script nonce="{{ csp_nonce }}">
17039  (function(){
17040    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'}];
17041    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);});}
17042    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17043    function init(){
17044      var btn=document.getElementById('settings-btn');if(!btn)return;
17045      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17046      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>';
17047      document.body.appendChild(m);
17048      var g=document.getElementById('scheme-grid');
17049      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);});
17050      var cl=document.getElementById('settings-close');
17051      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);
17052      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');});
17053      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17054      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17055    }
17056    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17057  }());
17058  </script>
17059</body>
17060</html>
17061"##,
17062    ext = "html"
17063)]
17064struct CompareSelectTemplate {
17065    version: &'static str,
17066    entries: Vec<HistoryEntryRow>,
17067    total_scans: usize,
17068    watched_dirs: Vec<String>,
17069    csp_nonce: String,
17070}
17071
17072// ── CompareTemplate ────────────────────────────────────────────────────────────
17073
17074#[derive(Template)]
17075#[template(
17076    source = r##"
17077<!doctype html>
17078<html lang="en">
17079<head>
17080  <meta charset="utf-8">
17081  <meta name="viewport" content="width=device-width, initial-scale=1">
17082  <title>OxideSLOC | Scan Delta</title>
17083  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17084  <style nonce="{{ csp_nonce }}">
17085    :root {
17086      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
17087      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
17088      --nav:#283790; --nav-2:#013e6b;
17089      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
17090      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
17091      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
17092    }
17093    body.dark-theme {
17094      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
17095      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
17096    }
17097    *{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);}
17098    .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);}
17099    .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;}
17100    .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));}
17101    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17102    .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;}
17103    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
17104    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17105    @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; } }
17106    .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;}
17107    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
17108    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17109    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17110    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17111    .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;}
17112    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17113    .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);}
17114    .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;}
17115    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17116    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17117    .settings-modal-body{padding:14px 16px 16px;}
17118    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17119    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17120    .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;}
17121    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17122    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17123    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17124    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17125    .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;}
17126    .tz-select:focus{border-color:var(--oxide);}
17127    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
17128    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
17129    .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;}
17130    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
17131    .hero-body{display:block;}
17132    .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;}
17133    .btn-back:hover{background:var(--line);}
17134    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
17135    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
17136    .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;}
17137    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
17138    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;}
17139    .muted{color:var(--muted);font-size:14px;}
17140    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
17141    .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;}
17142    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
17143    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
17144    .vpill-arrow{font-size:20px;color:var(--muted);}
17145    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
17146    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
17147    .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;}
17148    .delta-card.delta-card-wide{padding:22px 24px;}
17149    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
17150    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
17151    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
17152    .delta-card-from{font-size:15px;color:var(--muted);}
17153    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
17154    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
17155    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
17156    .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%;}
17157    .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;}
17158    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
17159    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
17160    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
17161    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
17162    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
17163    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
17164    .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;}
17165    .meta-card-commit:hover{color:var(--oxide);}
17166    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
17167    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
17168    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
17169    .meta-value{color:var(--text);font-size:13px;}
17170    .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;}
17171    .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);}
17172    .delta-card:hover .dc-tip{display:block;}
17173    .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;}
17174    .export-btn:hover{background:var(--line);}
17175    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
17176    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
17177    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
17178    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
17179    .delta-card-change.zero{color:var(--muted);background:transparent;}
17180    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
17181    .delta-card-pct.pos{color:var(--pos);}
17182    .delta-card-pct.neg{color:var(--neg);}
17183    .delta-card-pct.zero{color:var(--muted);}
17184    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
17185    .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;}
17186    .insight-card.insight-flag{border-color:var(--oxide);}
17187    .insight-card:hover .dc-tip{display:block;}
17188    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
17189    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
17190    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
17191    .insight-label.flag{color:var(--oxide);}
17192    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
17193    .insight-val.pos{color:var(--pos);}
17194    .insight-val.neg{color:var(--neg);}
17195    .insight-val.high{color:#c0392a;}
17196    .insight-val.med{color:#926000;}
17197    .insight-val.low{color:var(--pos);}
17198    body.dark-theme .insight-val.high{color:#ff6b6b;}
17199    body.dark-theme .insight-val.med{color:#f0c060;}
17200    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
17201    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
17202    .fc-row{display:flex;align-items:center;gap:8px;}
17203    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
17204    .fc-label{color:var(--muted);}
17205    .fc-modified .fc-count{color:#926000;}
17206    .fc-added .fc-count{color:var(--pos);}
17207    .fc-removed .fc-count{color:var(--neg);}
17208    .fc-unchanged .fc-count{color:var(--muted);}
17209    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
17210    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
17211    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
17212    .chip.modified{background:#fff2d8;color:#926000;}
17213    .chip.added{background:#e8f5ed;color:#1a8f47;}
17214    .chip.removed{background:#fdeaea;color:#b33b3b;}
17215    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
17216    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
17217    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
17218    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
17219    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
17220    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
17221    .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;}
17222    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
17223    .tab-btn:hover:not(.active){background:var(--line);}
17224    .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;}
17225    .btn-reset:hover{background:var(--line);}
17226    .table-wrap{width:100%;overflow-x:auto;}
17227    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
17228    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;}
17229    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
17230    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
17231    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
17232    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
17233    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
17234    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
17235    tr:last-child td{border-bottom:none;}
17236    tr.row-added td{background:rgba(26,143,71,0.06);}
17237    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
17238    tr.row-modified td{background:rgba(146,96,0,0.05);}
17239    tr.row-unchanged td{opacity:.6;}
17240    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
17241    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
17242    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
17243    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
17244    .status-badge.modified{background:#fff2d8;color:#926000;}
17245    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
17246    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
17247    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
17248    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
17249    .delta-val{font-weight:700;}
17250    .delta-val.pos{color:var(--pos);}
17251    .delta-val.neg{color:var(--neg);}
17252    .delta-val.zero{color:var(--muted);}
17253    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
17254    .from-to strong{color:var(--text);}
17255    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17256    .site-footer a{color:var(--muted);}
17257    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
17258    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
17259    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17260    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17261    .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;}
17262    .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;}
17263    .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;}
17264    @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));}}
17265    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
17266    .path-link:hover{color:var(--oxide-2);}
17267    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
17268    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
17269    a.vpill-id:hover{color:var(--oxide);}
17270    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
17271    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
17272    .pagination-info{font-size:13px;color:var(--muted);}
17273    .pagination-btns{display:flex;gap:6px;}
17274    .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;}
17275    .pg-btn:hover:not(:disabled){background:var(--line);}
17276    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17277    .pg-btn:disabled{opacity:.35;cursor:default;}
17278    .per-page-label{font-size:13px;color:var(--muted);}
17279    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;}
17280    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17281    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
17282    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
17283    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
17284    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
17285    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
17286    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
17287    .tab-btn.tab-unchanged{color:var(--muted);}
17288    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
17289    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
17290    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
17291    .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;}
17292    .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;}
17293    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
17294    .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;}
17295    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
17296    .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;}
17297    .submod-scope-btn:hover{background:var(--line);}
17298    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17299    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
17300    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
17301    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
17302    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
17303    body.dark-theme .ic-card{background:var(--surface-2);}
17304    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
17305    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
17306    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
17307    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
17308    #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;}
17309  </style>
17310</head>
17311<body>
17312  <div class="background-watermarks" aria-hidden="true">
17313    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17314    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17315    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17316    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17317    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17318    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17319  </div>
17320  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17321  <div class="top-nav">
17322    <div class="top-nav-inner">
17323      <a class="brand" href="/">
17324        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17325        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
17326      </a>
17327      <div class="nav-right">
17328        <a class="nav-pill" href="/">Home</a>
17329        <div class="nav-dropdown">
17330          <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>
17331          <div class="nav-dropdown-menu">
17332            <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>
17333          </div>
17334        </div>
17335        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17336        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17337        <div class="nav-dropdown">
17338          <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>
17339          <div class="nav-dropdown-menu">
17340            <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>
17341          </div>
17342        </div>
17343        <div class="server-status-wrap">
17344          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
17345          <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>
17346        </div>
17347        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17348          <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>
17349        </button>
17350        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17351          <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>
17352          <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>
17353        </button>
17354      </div>
17355    </div>
17356  </div>
17357
17358  <div class="page">
17359    <section class="hero">
17360      <div class="hero-header">
17361        <div>
17362          <h1 class="delta-title">Scan Delta</h1>
17363          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
17364          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
17365            {% if let Some(sub) = active_submodule %}
17366            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
17367            {% else if super_scope_active %}
17368            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
17369            {% else %}
17370            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
17371            {% endif %}
17372            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
17373          </div>
17374        </div>
17375        <a class="btn-back" href="/compare-scans">
17376          <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>
17377          Compare Scans
17378        </a>
17379      </div>
17380      {% if has_any_submodule_data %}
17381      <div class="submod-scope-bar">
17382        <span class="submod-scope-label">
17383          <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>
17384          Scope:
17385        </span>
17386        <div class="submod-scope-divider"></div>
17387        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
17388           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
17389           title="All files — super-repo and all submodules combined">Full scan</a>
17390        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
17391           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
17392           title="Only files that are not part of any submodule">Super-repo only</a>
17393        {% for sub in submodule_options %}
17394        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
17395           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
17396           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
17397        {% endfor %}
17398      </div>
17399      {% endif %}
17400      <div class="hero-body">
17401      <div class="meta-strip">
17402        <div class="delta-card delta-card-meta">
17403          <div class="meta-card-header">
17404            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
17405            <div class="meta-card-project-col">
17406              <div class="meta-card-project">{{ project_name }}</div>
17407              {% if has_any_submodule_data %}
17408              {% if let Some(sub) = active_submodule %}
17409              <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>
17410              {% else if super_scope_active %}
17411              <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>
17412              {% else %}
17413              <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>
17414              {% endif %}
17415              {% endif %}
17416            </div>
17417          </div>
17418          {% if !baseline_git_commit.is_empty() %}
17419          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
17420          {% else %}
17421          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
17422          {% endif %}
17423          <div class="meta-card-rows">
17424            <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>
17425            <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>
17426            <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>
17427            <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>
17428            {% if let Some(tags) = baseline_git_tags %}
17429            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17430            {% endif %}
17431          </div>
17432        </div>
17433        <div class="delta-card delta-card-meta">
17434          <div class="meta-card-header">
17435            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
17436            <div class="meta-card-project-col">
17437              <div class="meta-card-project">{{ project_name }}</div>
17438              {% if has_any_submodule_data %}
17439              {% if let Some(sub) = active_submodule %}
17440              <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>
17441              {% else if super_scope_active %}
17442              <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>
17443              {% else %}
17444              <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>
17445              {% endif %}
17446              {% endif %}
17447            </div>
17448          </div>
17449          {% if !current_git_commit.is_empty() %}
17450          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
17451          {% else %}
17452          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
17453          {% endif %}
17454          <div class="meta-card-rows">
17455            <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>
17456            <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>
17457            <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>
17458            <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>
17459            {% if let Some(tags) = current_git_tags %}
17460            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17461            {% endif %}
17462          </div>
17463        </div>
17464      </div>
17465      <div class="delta-strip">
17466        <div class="delta-card">
17467          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
17468          <div class="delta-card-label">Code lines</div>
17469          <div class="delta-card-from">Before: {{ baseline_code }}</div>
17470          <div class="delta-card-to">{{ current_code }}</div>
17471          {% 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>
17472          {% 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>
17473          {% else %}<div class="delta-card-pct zero">±0%</div>
17474          {% endif %}
17475        </div>
17476        <div class="delta-card">
17477          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
17478          <div class="delta-card-label">Files analyzed</div>
17479          <div class="delta-card-from">Before: {{ baseline_files }}</div>
17480          <div class="delta-card-to">{{ current_files }}</div>
17481          {% 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>
17482          {% 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>
17483          {% else %}<div class="delta-card-pct zero">±0%</div>
17484          {% endif %}
17485        </div>
17486        <div class="delta-card">
17487          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
17488          <div class="delta-card-label">Comment lines</div>
17489          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
17490          <div class="delta-card-to">{{ current_comments }}</div>
17491          {% 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>
17492          {% 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>
17493          {% else %}<div class="delta-card-pct zero">±0%</div>
17494          {% endif %}
17495        </div>
17496        <div class="delta-card delta-card-wide">
17497          <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>
17498          <div class="delta-card-label">File changes</div>
17499          <div class="file-changes-grid">
17500            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
17501            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
17502            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
17503            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
17504          </div>
17505        </div>
17506      </div>
17507      <div class="insights-panel">
17508        <div class="insight-card">
17509          <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>
17510          <div class="insight-label">Lines Added</div>
17511          <div class="insight-val pos">+{{ code_lines_added }}</div>
17512          <div class="insight-sub">New or grown source lines</div>
17513        </div>
17514        <div class="insight-card">
17515          <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>
17516          <div class="insight-label">Lines Removed</div>
17517          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
17518          <div class="insight-sub">Deleted or shrunk source lines</div>
17519        </div>
17520        <div class="insight-card">
17521          <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>
17522          <div class="insight-label">Churn Rate</div>
17523          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
17524          <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>
17525        </div>
17526        {% if scope_flag %}
17527        <div class="insight-card insight-flag">
17528          <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>
17529          <div class="insight-label flag">Scope Signal</div>
17530          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
17531          <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>
17532        </div>
17533        {% endif %}
17534      </div>
17535      </div>
17536    </section>
17537
17538    <section class="panel" id="inline-charts-section">
17539      <h2>Scan Delta Charts</h2>
17540      <div class="ic-grid">
17541        <div class="ic-card">
17542          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
17543          <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>
17544          <div id="ic-c1"></div>
17545        </div>
17546        <div class="ic-card" id="ic-lang-card">
17547          <div class="ic-card-h2">Language Code Delta</div>
17548          <div id="ic-c3"></div>
17549        </div>
17550        <div class="ic-card">
17551          <div class="ic-card-h2">Delta by Metric</div>
17552          <div id="ic-c2"></div>
17553        </div>
17554        <div class="ic-card">
17555          <div class="ic-card-h2">File Change Distribution</div>
17556          <div id="ic-c4"></div>
17557        </div>
17558      </div>
17559    </section>
17560
17561    <section class="panel">
17562      <h2>File-level delta</h2>
17563      <div class="filter-tabs-row">
17564        <div class="filter-tabs">
17565          <button class="tab-btn tab-all active" data-filter="all">All</button>
17566          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
17567          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
17568          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
17569          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
17570        </div>
17571        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
17572          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
17573          <div class="export-group">
17574            <button type="button" class="btn-reset" id="delta-reset-btn">&#8635; Reset</button>
17575            <button type="button" class="export-btn" id="delta-csv-btn">
17576              <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>
17577              CSV
17578            </button>
17579            <button type="button" class="export-btn" id="delta-xls-btn">
17580              <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>
17581              Excel
17582            </button>
17583            <button type="button" class="export-btn" id="delta-charts-btn">
17584              <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>
17585              Charts
17586            </button>
17587          </div>
17588        </div>
17589      </div>
17590
17591      <div class="table-wrap">
17592      <table id="delta-table">
17593        <colgroup>
17594          <col>
17595          <col>
17596          <col>
17597          <col>
17598          <col>
17599          <col>
17600          <col>
17601        </colgroup>
17602        <thead>
17603          <tr id="delta-thead">
17604            <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>
17605            <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>
17606            <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>
17607            <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>
17608            <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>
17609            <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>
17610            <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>
17611          </tr>
17612        </thead>
17613        <tbody id="delta-tbody">
17614          {% for row in file_rows %}
17615          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
17616              data-path="{{ row.relative_path }}"
17617              data-language="{{ row.language }}"
17618              data-baseline-code="{{ row.baseline_code }}"
17619              data-current-code="{{ row.current_code }}"
17620              data-code-delta="{{ row.code_delta_str }}"
17621              data-comment-delta="{{ row.comment_delta_str }}"
17622              data-total-delta="{{ row.total_delta_str }}"
17623              data-orig-idx="">
17624            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
17625            <td class="hide-sm">{{ row.language }}</td>
17626            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
17627            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
17628            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
17629            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
17630            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
17631          </tr>
17632          {% endfor %}
17633        </tbody>
17634      </table>
17635      </div>
17636      <div class="pagination">
17637        <span class="pagination-info" id="pg-info"></span>
17638        <div class="pagination-btns" id="pg-btns"></div>
17639        <div class="flex-row">
17640          <span class="per-page-label">Show</span>
17641          <select class="per-page" id="per-page-sel">
17642            <option value="10">10 per page</option>
17643            <option value="25" selected>25 per page</option>
17644            <option value="50">50 per page</option>
17645            <option value="100">100 per page</option>
17646          </select>
17647          <span class="per-page-label" id="pg-range-label"></span>
17648        </div>
17649      </div>
17650    </section>
17651  </div>
17652
17653  <div id="ic-tt"></div>
17654
17655  <footer class="site-footer">
17656    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
17657    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17658    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17659    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17660    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17661  </footer>
17662
17663  <script nonce="{{ csp_nonce }}">
17664    (function () {
17665      var storageKey = 'oxide-sloc-theme';
17666      var body = document.body;
17667      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17668      var toggle = document.getElementById('theme-toggle');
17669      if (toggle) toggle.addEventListener('click', function () {
17670        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17671        body.classList.toggle('dark-theme', next === 'dark');
17672        try { localStorage.setItem(storageKey, next); } catch(e) {}
17673      });
17674
17675      (function randomizeWatermarks() {
17676        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17677        if (!wms.length) return;
17678        var placed = [];
17679        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;}
17680        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];}
17681        var half=Math.floor(wms.length/2);
17682        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;});
17683      })();
17684
17685      (function spawnCodeParticles() {
17686        var container = document.getElementById('code-particles');
17687        if (!container) return;
17688        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'];
17689        for (var i = 0; i < 38; i++) {
17690          (function(idx) {
17691            var el = document.createElement('span');
17692            el.className = 'code-particle';
17693            el.textContent = snippets[idx % snippets.length];
17694            var left = Math.random() * 94 + 2;
17695            var top = Math.random() * 88 + 6;
17696            var dur = (Math.random() * 10 + 9).toFixed(1);
17697            var delay = (Math.random() * 18).toFixed(1);
17698            var rot = (Math.random() * 26 - 13).toFixed(1);
17699            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17700            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';
17701            container.appendChild(el);
17702          })(i);
17703        }
17704      })();
17705    })();
17706
17707    var activeStatusFilter = 'all';
17708    var deltaPerPage = 25, deltaCurrPage = 1;
17709
17710    function openFolder(path) {
17711      fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
17712    }
17713
17714    function getDeltaFilteredRows() {
17715      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
17716        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
17717      });
17718    }
17719
17720    function renderDeltaPage() {
17721      var filtered = getDeltaFilteredRows();
17722      var total = filtered.length;
17723      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
17724      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
17725      var start = (deltaCurrPage - 1) * deltaPerPage;
17726      var end = Math.min(start + deltaPerPage, total);
17727      var shownSet = {};
17728      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
17729      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
17730        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
17731      });
17732      var rl = document.getElementById('pg-range-label');
17733      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
17734      var info = document.getElementById('pg-info');
17735      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
17736      var btns = document.getElementById('pg-btns');
17737      if (!btns) return;
17738      btns.innerHTML = '';
17739      if (totalPages <= 1) return;
17740      function makeBtn(lbl, pg, active, disabled) {
17741        var b = document.createElement('button');
17742        b.className = 'pg-btn' + (active ? ' active' : '');
17743        b.textContent = lbl; b.disabled = disabled;
17744        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
17745        return b;
17746      }
17747      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
17748      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
17749      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
17750      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
17751    }
17752
17753    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
17754
17755    function filterRows(status, btn) {
17756      activeStatusFilter = status;
17757      deltaCurrPage = 1;
17758      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
17759        b.classList.remove('active');
17760      });
17761      if (btn) btn.classList.add('active');
17762      renderDeltaPage();
17763    }
17764
17765    // ── Sorting ──────────────────────────────────────────────────────────────
17766    var sortCol = null, sortOrder = 'asc';
17767    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
17768    (function() {
17769      var tbody = document.getElementById('delta-tbody');
17770      if (!tbody) return;
17771      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17772      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
17773    })();
17774
17775    function parseDeltaNum(str) {
17776      if (!str || str === '—') return 0;
17777      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
17778    }
17779
17780    sortHeaders.forEach(function(th) {
17781      th.addEventListener('click', function(e) {
17782        if (e.target.classList.contains('col-resize-handle')) return;
17783        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
17784        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
17785        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17786        th.classList.add('sort-' + sortOrder);
17787        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
17788        var tbody = document.getElementById('delta-tbody');
17789        if (!tbody) return;
17790        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17791        rows.sort(function(a, b) {
17792          var va, vb;
17793          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
17794          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
17795          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
17796          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
17797          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17798          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17799          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17800          else { va = ''; vb = ''; }
17801          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
17802          return va < vb ? 1 : va > vb ? -1 : 0;
17803        });
17804        rows.forEach(function(r) { tbody.appendChild(r); });
17805        deltaCurrPage = 1;
17806        renderDeltaPage();
17807        var activeBtn = document.querySelector('.tab-btn.active');
17808        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
17809        if (activeBtn) activeBtn.classList.add('active');
17810      });
17811    });
17812
17813    // ── Column resize ─────────────────────────────────────────────────────────
17814    (function() {
17815      var table = document.getElementById('delta-table');
17816      if (!table) return;
17817      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
17818      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
17819      ths.forEach(function(th, i) {
17820        var handle = th.querySelector('.col-resize-handle');
17821        if (!handle || !cols[i]) return;
17822        var startX, startW;
17823        handle.addEventListener('mousedown', function(e) {
17824          e.stopPropagation(); e.preventDefault();
17825          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
17826          handle.classList.add('dragging');
17827          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
17828          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
17829          document.addEventListener('mousemove', onMove);
17830          document.addEventListener('mouseup', onUp);
17831        });
17832      });
17833    })();
17834
17835    // ── Reset ─────────────────────────────────────────────────────────────────
17836    window.resetDeltaTable = function() {
17837      sortCol = null; sortOrder = 'asc';
17838      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17839      var tbody = document.getElementById('delta-tbody');
17840      if (tbody) {
17841        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17842        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
17843        rows.forEach(function(r) { tbody.appendChild(r); });
17844      }
17845      var table = document.getElementById('delta-table');
17846      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
17847      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
17848      activeStatusFilter = 'all';
17849      deltaCurrPage = 1;
17850      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
17851      var allBtn = document.querySelector('.tab-btn');
17852      if (allBtn) allBtn.classList.add('active');
17853      renderDeltaPage();
17854    };
17855
17856    renderDeltaPage();
17857
17858    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
17859    (function() {
17860      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
17861        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
17862      });
17863      var resetBtn = document.getElementById('delta-reset-btn');
17864      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
17865      var csvBtn = document.getElementById('delta-csv-btn');
17866      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
17867      var xlsBtn = document.getElementById('delta-xls-btn');
17868      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
17869      var chartsBtn = document.getElementById('delta-charts-btn');
17870      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
17871      var ppSel = document.getElementById('per-page-sel');
17872      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
17873      var pathLink = document.getElementById('project-path-link');
17874      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
17875    })();
17876
17877    // ── Export helpers ────────────────────────────────────────────────────────
17878    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
17879    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
17880    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);}
17881    function slocMakeXlsx(fname,sd,dr){
17882      var enc=new TextEncoder();
17883      // CRC-32 table
17884      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;}
17885      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;}
17886      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
17887      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
17888      // Shared string table
17889      var ss=[],si={};
17890      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
17891      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
17892      // Worksheet builder — each WS() call gets its own row counter R
17893      function WS(){
17894        var R=0,buf=[];
17895        function cl(c){return String.fromCharCode(65+c);}
17896        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
17897          '<v>'+S(v)+'</v></c>';}
17898        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
17899          (st?' s="'+st+'"':'')+'>'+
17900          '<v>'+(+v)+'</v></c>';}
17901        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
17902        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
17903          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
17904          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
17905          '<sheetFormatPr defaultRowHeight="15"/>'+
17906          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
17907        return{sc:sc,nc:nc,row:row,xml:xml};
17908      }
17909      // Language breakdown
17910      var lm={};
17911      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;});
17912      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
17913      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
17914      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
17915      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
17916      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
17917      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
17918      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):'';}
17919      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
17920      // Summary sheet
17921      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
17922      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
17923      r1(s1(0,proj,2));
17924      r1(s1(0,sd.bts+' → '+sd.cts,2));
17925      r1('');
17926      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
17927      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))));
17928      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))));
17929      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))));
17930      r1('');
17931      r1(s1(0,'FILE CHANGES',8));
17932      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
17933      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
17934      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
17935      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
17936      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
17937      if(langs.length){
17938        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
17939        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
17940        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)));});
17941      }
17942      r1('');r1(s1(0,'SCAN METADATA',8));
17943      r1(s1(1,_blabel)+s1(2,_clabel));
17944      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
17945      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
17946      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"/>');
17947      // File Delta sheet
17948      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
17949      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));
17950      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)));});
17951      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
17952      // Shared strings XML
17953      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
17954        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
17955        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
17956      // XLSX file map
17957      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
17958      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>',
17959        '_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>',
17960        '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>',
17961        '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>',
17962        '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>',
17963        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
17964      // ZIP packer — STORED (no compression), compatible with all XLSX readers
17965      var zparts=[],zcds=[],zoff=0,znf=0;
17966      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
17967       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
17968      ].forEach(function(name){
17969        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
17970        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]);
17971        var entry=new Uint8Array(lha.length+nb.length+sz);
17972        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
17973        zparts.push(entry);
17974        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));
17975        var cde=new Uint8Array(cda.length+nb.length);
17976        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
17977        zcds.push(cde);zoff+=entry.length;znf++;
17978      });
17979      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
17980      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]);
17981      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
17982      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
17983      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
17984      zout.set(new Uint8Array(ea),zpos);
17985      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
17986      var xurl=URL.createObjectURL(xblob);
17987      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
17988      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
17989      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
17990    }
17991    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;');}
17992    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
17993    function getExportFilename(ext){return _exportBase+'.'+ext;}
17994
17995    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 }}'};
17996    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;}
17997    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
17998    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
17999    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
18000    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
18001    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):'';}
18002    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
18003    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)]];}
18004    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
18005    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;}
18006    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
18007    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
18008
18009    // ── Chart HTML report ─────────────────────────────────────────────────────
18010    function slocChartReport(fname, sd, dr) {
18011      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
18012      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18013      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18014      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();}
18015      function px(n){return Math.round(n);}
18016      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
18017      // Language map
18018      var lm={};
18019      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;});
18020      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18021
18022      // Builds onmouse* attrs for interactive tooltip on each SVG element
18023      function barTT(label,val){
18024        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
18025      }
18026
18027      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
18028      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'}];
18029      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18030      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
18031      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
18032      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18033      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"/>';}
18034      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18035      c1mets.forEach(function(m,i){
18036        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18037        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18038        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>';
18039        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))+'/>';
18040        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>';
18041        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))+'/>';
18042        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>';
18043        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>';
18044        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>';
18045      });
18046      c1+='</svg>';
18047
18048      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
18049      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'}];
18050      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18051      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
18052      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
18053      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18054      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18055      mets.forEach(function(m,i){
18056        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
18057        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
18058        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
18059        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>';
18060        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
18061        if(bw>=52){
18062          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>';
18063        }else{
18064          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
18065          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>';
18066        }
18067      });
18068      c2+='</svg>';
18069
18070      // ── Chart 3: Language Code Delta ─────────────────────────────────────
18071      var c3='';
18072      if(langs.length){
18073        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18074        var C3W=550,c3LW=124,c3FW=52;
18075        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
18076        var L3rH=30,C3H=langs.length*L3rH+20;
18077        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18078        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18079        langs.forEach(function(l,i){
18080          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
18081          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
18082          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
18083          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18084          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':''))+'/>';
18085          if(bw>=48){
18086            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>';
18087          }else{
18088            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
18089            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>';
18090          }
18091          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>';
18092        });
18093        c3+='</svg>';
18094      }
18095
18096      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
18097      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;});
18098      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18099      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
18100      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18101      var ang=-Math.PI/2;
18102      segs.forEach(function(s){
18103        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18104        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
18105        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
18106        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
18107        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
18108        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)+'%')+'/>';
18109        ang+=sw;
18110      });
18111      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>';
18112      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18113      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>';});
18114      c4+='</svg>';
18115
18116      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
18117      var ttJs='var tt=document.getElementById("ox-tt");'+
18118        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
18119        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
18120        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
18121        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
18122        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
18123        'function oxHT(){tt.style.display="none";}';
18124
18125      // body max-width keeps charts from inflating beyond design dimensions on
18126      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
18127      // each chart's height blows up proportionally, breaking the one-page layout.
18128      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;}'+
18129        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
18130        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
18131        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
18132        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
18133        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
18134        'svg{display:block;}'+
18135        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
18136        '#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;}'+
18137        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
18138      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
18139        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
18140        '<div id="ox-tt"><\/div>'+
18141        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
18142        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
18143        '<div class="two-col">'+
18144        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
18145        '<div class="leg">'+
18146        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
18147        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
18148        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
18149        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
18150        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
18151        '<\/div>'+
18152        '<div class="two-col">'+
18153        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
18154        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
18155        '<\/div>'+
18156        '<script>'+ttJs+'<\/script>'+
18157        '<\/body><\/html>';
18158      slocDownload(html, fname, 'text/html;charset=utf-8;');
18159    }
18160    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
18161    // ── Inline delta charts ────────────────────────────────────────────────────
18162    var _icTT=document.getElementById('ic-tt');
18163    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
18164    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';};
18165    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
18166    (function(){
18167      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
18168      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18169      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();}
18170      function px(n){return Math.round(n);}
18171      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18172      function btt(l,v){return ' class="ic-cb" onmouseover="icTT(event,\''+jsq(l)+'\',\''+jsq(v)+'\')" onmouseout="icHT()" onmousemove="icMT(event)"';}
18173      var dr=getDeltaExportRows(),sd=_sd,lm={};
18174      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;});
18175      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18176      // Chart 1: Baseline vs Current grouped bars
18177      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'}];
18178      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18179      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;
18180      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18181      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"/>';}
18182      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18183      c1mets.forEach(function(m,i){
18184        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18185        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18186        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>';
18187        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"/>';
18188        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>';
18189        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"/>';
18190        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>';
18191        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>';
18192        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>';
18193      });
18194      c1+='</svg>';
18195      // Chart 2: Delta by Metric
18196      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'}];
18197      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18198      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;
18199      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18200      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18201      mets.forEach(function(m,i){
18202        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);
18203        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>';
18204        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
18205        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>';}
18206        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>';}
18207      });
18208      c2+='</svg>';
18209      // Chart 3: Language Code Delta
18210      var c3='';
18211      if(langs.length){
18212        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18213        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;
18214        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18215        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18216        langs.forEach(function(l,i){
18217          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);
18218          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18219          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"/>';
18220          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>';}
18221          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>';}
18222          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>';
18223        });
18224        c3+='</svg>';
18225      }
18226      // Chart 4: File Change Donut
18227      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;});
18228      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18229      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;
18230      if(segs.length===1){
18231        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
18232        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
18233        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
18234      } else {
18235        segs.forEach(function(s){
18236          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18237          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);
18238          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);
18239          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"/>';
18240          ang+=sw;
18241        });
18242      }
18243      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>';
18244      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18245      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>';});
18246      c4+='</svg>';
18247      var e1=document.getElementById('ic-c1');if(e1)e1.innerHTML=c1;
18248      var e2=document.getElementById('ic-c2');if(e2)e2.innerHTML=c2;
18249      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>';
18250      var e4=document.getElementById('ic-c4');if(e4)e4.innerHTML=c4;
18251      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
18252    })();
18253  </script>
18254  <script nonce="{{ csp_nonce }}">
18255  (function(){
18256    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'}];
18257    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);});}
18258    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18259    function init(){
18260      var btn=document.getElementById('settings-btn');if(!btn)return;
18261      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18262      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>';
18263      document.body.appendChild(m);
18264      var g=document.getElementById('scheme-grid');
18265      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);});
18266      var cl=document.getElementById('settings-close');
18267      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);
18268      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');});
18269      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18270      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18271    }
18272    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18273  }());
18274  </script>
18275</body>
18276</html>
18277"##,
18278    ext = "html"
18279)]
18280// Template structs need many bool fields to pass Askama rendering flags.
18281#[allow(clippy::struct_excessive_bools)]
18282struct CompareTemplate {
18283    version: &'static str,
18284    project_label: String,
18285    baseline_git_commit: String,
18286    current_git_commit: String,
18287    baseline_run_id: String,
18288    current_run_id: String,
18289    baseline_run_id_short: String,
18290    current_run_id_short: String,
18291    baseline_timestamp: String,
18292    baseline_timestamp_utc_ms: i64,
18293    current_timestamp: String,
18294    current_timestamp_utc_ms: i64,
18295    project_path: String,
18296    baseline_code: u64,
18297    current_code: u64,
18298    code_lines_delta_str: String,
18299    code_lines_delta_class: String,
18300    baseline_files: u64,
18301    current_files: u64,
18302    files_analyzed_delta_str: String,
18303    files_analyzed_delta_class: String,
18304    baseline_comments: u64,
18305    current_comments: u64,
18306    comment_lines_delta_str: String,
18307    comment_lines_delta_class: String,
18308    code_lines_pct_str: String,
18309    files_analyzed_pct_str: String,
18310    comment_lines_pct_str: String,
18311    code_lines_added: i64,
18312    code_lines_removed: i64,
18313    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
18314    new_scope: bool,
18315    churn_rate_str: String,
18316    churn_rate_class: String,
18317    scope_flag: bool,
18318    files_added: usize,
18319    files_removed: usize,
18320    files_modified: usize,
18321    files_unchanged: usize,
18322    file_rows: Vec<CompareFileDeltaRow>,
18323    baseline_git_author: Option<String>,
18324    current_git_author: Option<String>,
18325    baseline_git_branch: String,
18326    current_git_branch: String,
18327    baseline_git_tags: Option<String>,
18328    current_git_tags: Option<String>,
18329    baseline_git_commit_date: Option<String>,
18330    current_git_commit_date: Option<String>,
18331    project_name: String,
18332    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
18333    submodule_options: Vec<String>,
18334    /// True when either run has submodule data — controls whether the scope bar is shown.
18335    has_any_submodule_data: bool,
18336    /// The submodule currently being compared, if the `sub` query param was provided.
18337    active_submodule: Option<String>,
18338    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
18339    super_scope_active: bool,
18340    csp_nonce: String,
18341}
18342
18343// ── LoginTemplate ──────────────────────────────────────────────────────────────
18344
18345#[derive(Template)]
18346#[template(
18347    source = r##"
18348<!doctype html>
18349<html lang="en">
18350<head>
18351  <meta charset="utf-8">
18352  <meta name="viewport" content="width=device-width, initial-scale=1">
18353  <title>OxideSLOC | Sign In</title>
18354  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18355  <style nonce="{{ csp_nonce }}">
18356    :root {
18357      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
18358      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
18359      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
18360      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
18361    }
18362    *{box-sizing:border-box;}
18363    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);}
18364    .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);}
18365    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
18366    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
18367    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
18368    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18369    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18370    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18371    .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;}
18372    @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));}}
18373    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
18374    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
18375    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18376    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
18377    .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;}
18378    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
18379    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;}
18380    input[type=password]:focus{border-color:var(--oxide);}
18381    .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;}
18382    .btn:hover{opacity:.88;}
18383    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
18384    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
18385  </style>
18386</head>
18387<body>
18388  <div class="background-watermarks" aria-hidden="true">
18389    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18390    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18391    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18392    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18393    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18394    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18395    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18396  </div>
18397  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18398<nav class="top-nav">
18399  <a class="brand" href="/">
18400    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
18401    <span class="brand-title">OxideSLOC</span>
18402  </a>
18403</nav>
18404<main class="page">
18405  <div class="card">
18406    <h1>Sign In</h1>
18407    <p class="subtitle">Enter the API key printed when the server started.</p>
18408    {% if has_error %}
18409    <div class="error">Incorrect API key — please try again.</div>
18410    {% endif %}
18411    <form method="POST" action="/auth/login">
18412      <input type="hidden" name="next" value="{{ next_url|e }}">
18413      <label for="key">API Key</label>
18414      <input id="key" type="password" name="key" autocomplete="current-password"
18415             placeholder="Paste your API key here" autofocus>
18416      <button type="submit" class="btn">Sign In</button>
18417    </form>
18418    <p class="hint">
18419      The API key was printed in the terminal when the server started.<br>
18420      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
18421      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
18422    </p>
18423  </div>
18424</main>
18425<script nonce="{{ csp_nonce }}">
18426(function() {
18427  (function randomizeWatermarks() {
18428    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18429    if (!wms.length) return;
18430    var placed = [];
18431    function tooClose(top, left) {
18432      for (var i = 0; i < placed.length; i++) {
18433        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
18434        if (dt < 16 && dl < 12) return true;
18435      }
18436      return false;
18437    }
18438    function pick(leftBand) {
18439      for (var attempt = 0; attempt < 50; attempt++) {
18440        var top = Math.random() * 88 + 2;
18441        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18442        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18443      }
18444      var top = Math.random() * 88 + 2;
18445      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18446      placed.push([top, left]); return [top, left];
18447    }
18448    var half = Math.floor(wms.length / 2);
18449    wms.forEach(function (img, i) {
18450      var pos = pick(i < half);
18451      var size = Math.floor(Math.random() * 100 + 120);
18452      var rot = (Math.random() * 360).toFixed(1);
18453      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
18454      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;
18455    });
18456  })();
18457  (function spawnCodeParticles() {
18458    var container = document.getElementById('code-particles');
18459    if (!container) return;
18460    var snippets = [
18461      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
18462      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
18463      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
18464      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
18465      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
18466    ];
18467    var count = 38;
18468    for (var i = 0; i < count; i++) {
18469      (function(idx) {
18470        var el = document.createElement('span');
18471        el.className = 'code-particle';
18472        el.textContent = snippets[idx % snippets.length];
18473        var left = Math.random() * 94 + 2;
18474        var top = Math.random() * 88 + 6;
18475        var dur = (Math.random() * 10 + 9).toFixed(1);
18476        var delay = (Math.random() * 18).toFixed(1);
18477        var rot = (Math.random() * 26 - 13).toFixed(1);
18478        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18479        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
18480        container.appendChild(el);
18481      })(i);
18482    }
18483  })();
18484})();
18485</script>
18486</body>
18487</html>
18488"##,
18489    ext = "html"
18490)]
18491struct LoginTemplate {
18492    csp_nonce: String,
18493    has_error: bool,
18494    next_url: String,
18495    lockout_threshold: u32,
18496}
18497
18498// ── REST API reference page ────────────────────────────────────────────────────
18499
18500#[derive(Template)]
18501#[template(
18502    source = r##"
18503<!doctype html>
18504<html lang="en">
18505<head>
18506  <meta charset="utf-8">
18507  <meta name="viewport" content="width=device-width, initial-scale=1">
18508  <title>OxideSLOC — REST API Reference</title>
18509  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18510  <style nonce="{{ csp_nonce }}">
18511    :root {
18512      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18513      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18514      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18515      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18516      --success:#16a34a;
18517    }
18518    body.dark-theme {
18519      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
18520      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
18521    }
18522    *{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);}
18523    .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);}
18524    .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;}
18525    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
18526    .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));}
18527    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
18528    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
18529    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
18530    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
18531    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18532    @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; } }
18533    .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;}
18534    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
18535    .nav-pill.active{background:rgba(255,255,255,0.22);}
18536    .nav-dropdown{position:relative;display:inline-flex;}
18537    .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;}
18538    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
18539    .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;}
18540    .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;}
18541    .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);}
18542    .nav-dropdown-menu a:last-child{border-bottom:none;}
18543    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
18544    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
18545    .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;}
18546    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18547    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18548    .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;}
18549    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18550    .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);}
18551    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
18552    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
18553    .settings-modal-body{padding:14px 16px 16px;}
18554    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18555    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18556    .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;}
18557    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18558    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18559    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18560    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18561    .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;}
18562    .tz-select:focus{border-color:var(--oxide);}
18563    .page{max-width:960px;margin:0 auto;padding:40px 24px 60px;position:relative;z-index:1;}
18564    .page-header{margin-bottom:28px;}
18565    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
18566    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
18567    .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;}
18568    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
18569    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
18570    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
18571    .callout strong{font-weight:800;}
18572    .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;}
18573    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
18574    .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;}
18575    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
18576    .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;}
18577    body.dark-theme .base-url-value{color:var(--accent);}
18578    .section{margin-bottom:36px;}
18579    .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);}
18580    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
18581    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
18582    .ep-header:hover{background:var(--surface-2);}
18583    .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;}
18584    .method.get{background:#dcfce7;color:#166534;}
18585    .method.post{background:#dbeafe;color:#1e40af;}
18586    .method.delete{background:#fee2e2;color:#991b1b;}
18587    body.dark-theme .method.get{background:#14532d;color:#86efac;}
18588    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
18589    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
18590    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
18591    .ep-path .param{color:var(--oxide-2);}
18592    body.dark-theme .ep-path .param{color:var(--oxide);}
18593    .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;}
18594    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
18595    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
18596    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
18597    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
18598    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
18599    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
18600    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
18601    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
18602    .ep-card.open .chevron{transform:rotate(180deg);}
18603    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
18604    .ep-card.open .ep-body{display:block;}
18605    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
18606    .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;}
18607    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
18608    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
18609    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18610    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
18611    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);}
18612    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
18613    table.params tr:last-child td{border-bottom:none;}
18614    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
18615    .pt-type{color:var(--muted-2);font-size:12px;}
18616    .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;}
18617    .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;}
18618    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
18619    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
18620    details.schema{margin-bottom:14px;}
18621    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;}
18622    details.schema summary:hover{color:var(--text);}
18623    .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;}
18624    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18625    .curl-wrap{position:relative;}
18626    .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;}
18627    .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;}
18628    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
18629    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
18630    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
18631    .webhook-note a{color:var(--accent-2);text-decoration:none;}
18632    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18633    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18634    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18635    .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;}
18636    @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));}}
18637    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18638    .site-footer a{color:var(--muted);}
18639  </style>
18640</head>
18641<body>
18642  <div class="background-watermarks" aria-hidden="true">
18643    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18644    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18645    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18646    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18647    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18648    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18649    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18650  </div>
18651  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18652  <div class="top-nav">
18653    <div class="top-nav-inner">
18654      <a class="brand" href="/">
18655        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18656        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
18657      </a>
18658      <div class="nav-right">
18659        <a class="nav-pill" href="/">Home</a>
18660        <div class="nav-dropdown">
18661          <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>
18662          <div class="nav-dropdown-menu">
18663            <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>
18664          </div>
18665        </div>
18666        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18667        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18668        <div class="nav-dropdown">
18669          <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>
18670          <div class="nav-dropdown-menu">
18671            <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>
18672          </div>
18673        </div>
18674        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18675          <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>
18676        </button>
18677        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18678          <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>
18679          <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>
18680        </button>
18681      </div>
18682    </div>
18683  </div>
18684
18685  <div class="page">
18686    <div class="page-header">
18687      <h1 class="page-title">REST API Reference</h1>
18688      <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>
18689    </div>
18690
18691    {% if has_api_key %}
18692    <div class="callout key-set">
18693      <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>
18694      <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>
18695    </div>
18696    {% else %}
18697    <div class="callout no-key">
18698      <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>
18699      <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>
18700    </div>
18701    {% endif %}
18702
18703    <div class="base-url-bar">
18704      <span class="base-url-label">Base URL</span>
18705      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
18706    </div>
18707
18708    <!-- Health -->
18709    <div class="section">
18710      <h2 class="section-title">Health &amp; Status</h2>
18711      <div class="ep-card">
18712        <div class="ep-header">
18713          <span class="method get">GET</span>
18714          <span class="ep-path">/healthz</span>
18715          <span class="auth-badge public">Public</span>
18716          <span class="ep-desc">Server liveness check</span>
18717          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18718        </div>
18719        <div class="ep-body">
18720          <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>
18721          <p class="params-heading">Response</p>
18722          <div class="schema-block">200 OK
18723Content-Type: text/plain
18724
18725ok</div>
18726          <p class="curl-heading">Example</p>
18727          <div class="curl-wrap">
18728            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
18729            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
18730          </div>
18731        </div>
18732      </div>
18733    </div>
18734
18735    <!-- Badges -->
18736    <div class="section">
18737      <h2 class="section-title">Badges</h2>
18738      <div class="ep-card">
18739        <div class="ep-header">
18740          <span class="method get">GET</span>
18741          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
18742          <span class="auth-badge public">Public</span>
18743          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
18744          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18745        </div>
18746        <div class="ep-body">
18747          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
18748          <p class="params-heading">Path Parameters</p>
18749          <table class="params">
18750            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18751            <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>
18752          </table>
18753          <p class="curl-heading">Example</p>
18754          <div class="curl-wrap">
18755            <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>
18756            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
18757          </div>
18758        </div>
18759      </div>
18760    </div>
18761
18762    <!-- Metrics -->
18763    <div class="section">
18764      <h2 class="section-title">Metrics</h2>
18765
18766      <div class="ep-card">
18767        <div class="ep-header">
18768          <span class="method get">GET</span>
18769          <span class="ep-path">/api/metrics/latest</span>
18770          <span class="auth-badge protected">Protected</span>
18771          <span class="ep-desc">Latest scan metrics (JSON)</span>
18772          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18773        </div>
18774        <div class="ep-body">
18775          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
18776          <details class="schema"><summary>Response schema</summary>
18777<div class="schema-block">{
18778  "run_id":    string,        // UUID
18779  "timestamp": string,        // ISO-8601 UTC
18780  "project":   string,        // scanned root path
18781  "summary": {
18782    "files_analyzed":       number,
18783    "files_skipped":        number,
18784    "code_lines":           number,
18785    "comment_lines":        number,
18786    "blank_lines":          number,
18787    "total_physical_lines": number,
18788    "functions":            number,
18789    "classes":              number,
18790    "variables":            number,
18791    "imports":              number
18792  },
18793  "languages": [
18794    { "name": string, "files": number, "code_lines": number,
18795      "comment_lines": number, "blank_lines": number,
18796      "functions": number, "classes": number,
18797      "variables": number, "imports": number }
18798  ]
18799}</div></details>
18800          <p class="curl-heading">Example</p>
18801          <div class="curl-wrap">
18802            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18803  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
18804            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
18805          </div>
18806        </div>
18807      </div>
18808
18809      <div class="ep-card">
18810        <div class="ep-header">
18811          <span class="method get">GET</span>
18812          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
18813          <span class="auth-badge protected">Protected</span>
18814          <span class="ep-desc">Metrics for a specific run</span>
18815          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18816        </div>
18817        <div class="ep-body">
18818          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
18819          <p class="params-heading">Path Parameters</p>
18820          <table class="params">
18821            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18822            <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>
18823          </table>
18824          <p class="curl-heading">Example</p>
18825          <div class="curl-wrap">
18826            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18827  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
18828            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
18829          </div>
18830        </div>
18831      </div>
18832
18833      <div class="ep-card">
18834        <div class="ep-header">
18835          <span class="method get">GET</span>
18836          <span class="ep-path">/api/metrics/history</span>
18837          <span class="auth-badge protected">Protected</span>
18838          <span class="ep-desc">Paginated scan history</span>
18839          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18840        </div>
18841        <div class="ep-body">
18842          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
18843          <p class="params-heading">Query Parameters</p>
18844          <table class="params">
18845            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18846            <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>
18847            <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>
18848          </table>
18849          <details class="schema"><summary>Response schema</summary>
18850<div class="schema-block">[{
18851  "run_id":         string,
18852  "timestamp":      string,   // ISO-8601 UTC
18853  "commit":         string | null,
18854  "branch":         string | null,
18855  "tags":           string[],
18856  "code_lines":     number,
18857  "comment_lines":  number,
18858  "blank_lines":    number,
18859  "physical_lines": number,
18860  "files_analyzed": number,
18861  "project_label":  string,
18862  "html_url":       string | null
18863}]</div></details>
18864          <p class="curl-heading">Example</p>
18865          <div class="curl-wrap">
18866            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18867  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
18868            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
18869          </div>
18870        </div>
18871      </div>
18872
18873      <div class="ep-card">
18874        <div class="ep-header">
18875          <span class="method get">GET</span>
18876          <span class="ep-path">/api/project-history</span>
18877          <span class="auth-badge protected">Protected</span>
18878          <span class="ep-desc">Project-level scan summary</span>
18879          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18880        </div>
18881        <div class="ep-body">
18882          <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>
18883          <p class="params-heading">Query Parameters</p>
18884          <table class="params">
18885            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18886            <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>
18887          </table>
18888          <details class="schema"><summary>Response schema</summary>
18889<div class="schema-block">{
18890  "scan_count":           number,
18891  "last_scan_id":         string | null,
18892  "last_scan_timestamp":  string | null,  // ISO-8601
18893  "last_scan_code_lines": number | null,
18894  "last_git_branch":      string | null,
18895  "last_git_commit":      string | null
18896}</div></details>
18897          <p class="curl-heading">Example</p>
18898          <div class="curl-wrap">
18899            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18900  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
18901            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
18902          </div>
18903        </div>
18904      </div>
18905
18906      <div class="ep-card">
18907        <div class="ep-header">
18908          <span class="method get">GET</span>
18909          <span class="ep-path">/api/metrics/submodules</span>
18910          <span class="auth-badge protected">Protected</span>
18911          <span class="ep-desc">List known git submodules across scans</span>
18912          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18913        </div>
18914        <div class="ep-body">
18915          <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>
18916          <p class="params-heading">Query Parameters</p>
18917          <table class="params">
18918            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18919            <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>
18920          </table>
18921          <details class="schema"><summary>Response schema</summary>
18922<div class="schema-block">[{
18923  "name":          string,  // submodule name
18924  "relative_path": string   // path relative to the project root
18925}]</div></details>
18926          <p class="curl-heading">Example</p>
18927          <div class="curl-wrap">
18928            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18929  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
18930            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
18931          </div>
18932        </div>
18933      </div>
18934    </div>
18935
18936    <!-- Async Run Status -->
18937    <div class="section">
18938      <h2 class="section-title">Async Run Status</h2>
18939
18940      <div class="ep-card">
18941        <div class="ep-header">
18942          <span class="method get">GET</span>
18943          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
18944          <span class="auth-badge protected">Protected</span>
18945          <span class="ep-desc">Poll scan completion</span>
18946          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18947        </div>
18948        <div class="ep-body">
18949          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
18950          <details class="schema"><summary>Response schema</summary>
18951<div class="schema-block">// Running
18952{ "state": "running",  "elapsed_secs": number }
18953
18954// Complete
18955{ "state": "complete", "run_id": string }
18956
18957// Failed
18958{ "state": "failed",   "message": string }</div></details>
18959          <p class="curl-heading">Example</p>
18960          <div class="curl-wrap">
18961            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18962  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
18963            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
18964          </div>
18965        </div>
18966      </div>
18967
18968      <div class="ep-card">
18969        <div class="ep-header">
18970          <span class="method get">GET</span>
18971          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
18972          <span class="auth-badge protected">Protected</span>
18973          <span class="ep-desc">Poll PDF generation readiness</span>
18974          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18975        </div>
18976        <div class="ep-body">
18977          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
18978          <details class="schema"><summary>Response schema</summary>
18979<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
18980          <p class="curl-heading">Example</p>
18981          <div class="curl-wrap">
18982            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18983  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
18984            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
18985          </div>
18986        </div>
18987      </div>
18988
18989      <div class="ep-card">
18990        <div class="ep-header">
18991          <span class="method post">POST</span>
18992          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
18993          <span class="auth-badge protected">Protected</span>
18994          <span class="ep-desc">Cancel a running scan</span>
18995          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18996        </div>
18997        <div class="ep-body">
18998          <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>
18999          <p class="curl-heading">Example</p>
19000          <div class="curl-wrap">
19001            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
19002  -H "Authorization: Bearer $SLOC_API_KEY" \
19003  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
19004            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
19005          </div>
19006        </div>
19007      </div>
19008    </div>
19009
19010    <!-- Scan Profiles -->
19011    <div class="section">
19012      <h2 class="section-title">Scan Profiles</h2>
19013
19014      <div class="ep-card">
19015        <div class="ep-header">
19016          <span class="method get">GET</span>
19017          <span class="ep-path">/api/scan-profiles</span>
19018          <span class="auth-badge protected">Protected</span>
19019          <span class="ep-desc">List saved scan profiles</span>
19020          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19021        </div>
19022        <div class="ep-body">
19023          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
19024          <details class="schema"><summary>Response schema</summary>
19025<div class="schema-block">{
19026  "profiles": [{
19027    "id":         string,   // UUID
19028    "name":       string,
19029    "created_at": string,   // ISO-8601
19030    "params":     object
19031  }]
19032}</div></details>
19033          <p class="curl-heading">Example</p>
19034          <div class="curl-wrap">
19035            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19036  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19037            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
19038          </div>
19039        </div>
19040      </div>
19041
19042      <div class="ep-card">
19043        <div class="ep-header">
19044          <span class="method post">POST</span>
19045          <span class="ep-path">/api/scan-profiles</span>
19046          <span class="auth-badge protected">Protected</span>
19047          <span class="ep-desc">Save a scan profile</span>
19048          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19049        </div>
19050        <div class="ep-body">
19051          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
19052          <p class="params-heading">Request Body (application/json)</p>
19053          <table class="params">
19054            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19055            <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>
19056            <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>
19057          </table>
19058          <details class="schema"><summary>Response schema</summary>
19059<div class="schema-block">{ "ok": true }</div></details>
19060          <p class="curl-heading">Example</p>
19061          <div class="curl-wrap">
19062            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
19063  -H "Authorization: Bearer $SLOC_API_KEY" \
19064  -H "Content-Type: application/json" \
19065  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
19066  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19067            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
19068          </div>
19069        </div>
19070      </div>
19071
19072      <div class="ep-card">
19073        <div class="ep-header">
19074          <span class="method delete">DELETE</span>
19075          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
19076          <span class="auth-badge protected">Protected</span>
19077          <span class="ep-desc">Delete a scan profile</span>
19078          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19079        </div>
19080        <div class="ep-body">
19081          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
19082          <p class="params-heading">Path Parameters</p>
19083          <table class="params">
19084            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19085            <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>
19086          </table>
19087          <details class="schema"><summary>Response schema</summary>
19088<div class="schema-block">{ "ok": true }</div></details>
19089          <p class="curl-heading">Example</p>
19090          <div class="curl-wrap">
19091            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
19092  -H "Authorization: Bearer $SLOC_API_KEY" \
19093  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
19094            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
19095          </div>
19096        </div>
19097      </div>
19098    </div>
19099
19100    <!-- Scheduled Scans -->
19101    <div class="section">
19102      <h2 class="section-title">Scheduled Scans</h2>
19103
19104      <div class="ep-card">
19105        <div class="ep-header">
19106          <span class="method get">GET</span>
19107          <span class="ep-path">/api/schedules</span>
19108          <span class="auth-badge protected">Protected</span>
19109          <span class="ep-desc">List configured schedules</span>
19110          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19111        </div>
19112        <div class="ep-body">
19113          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
19114          <p class="curl-heading">Example</p>
19115          <div class="curl-wrap">
19116            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19117  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19118            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
19119          </div>
19120        </div>
19121      </div>
19122
19123      <div class="ep-card">
19124        <div class="ep-header">
19125          <span class="method post">POST</span>
19126          <span class="ep-path">/api/schedules</span>
19127          <span class="auth-badge protected">Protected</span>
19128          <span class="ep-desc">Create a schedule</span>
19129          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19130        </div>
19131        <div class="ep-body">
19132          <p class="ep-desc-full">Creates a new scheduled scan. Use the <a href="/integrations">Integrations UI</a> to configure the full field set interactively.</p>
19133          <p class="curl-heading">Example</p>
19134          <div class="curl-wrap">
19135            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
19136  -H "Authorization: Bearer $SLOC_API_KEY" \
19137  -H "Content-Type: application/json" \
19138  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
19139  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19140            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
19141          </div>
19142        </div>
19143      </div>
19144
19145      <div class="ep-card">
19146        <div class="ep-header">
19147          <span class="method delete">DELETE</span>
19148          <span class="ep-path">/api/schedules</span>
19149          <span class="auth-badge protected">Protected</span>
19150          <span class="ep-desc">Delete a schedule</span>
19151          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19152        </div>
19153        <div class="ep-body">
19154          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
19155          <p class="curl-heading">Example</p>
19156          <div class="curl-wrap">
19157            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
19158  -H "Authorization: Bearer $SLOC_API_KEY" \
19159  -H "Content-Type: application/json" \
19160  -d '{"id":"&lt;schedule_id&gt;"}' \
19161  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19162            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
19163          </div>
19164        </div>
19165      </div>
19166    </div>
19167
19168    <!-- Git Browser -->
19169    <div class="section">
19170      <h2 class="section-title">Git Browser</h2>
19171
19172      <div class="ep-card">
19173        <div class="ep-header">
19174          <span class="method get">GET</span>
19175          <span class="ep-path">/api/git/refs</span>
19176          <span class="auth-badge protected">Protected</span>
19177          <span class="ep-desc">List git refs for a repository</span>
19178          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19179        </div>
19180        <div class="ep-body">
19181          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
19182          <p class="params-heading">Query Parameters</p>
19183          <table class="params">
19184            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19185            <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>
19186          </table>
19187          <p class="curl-heading">Example</p>
19188          <div class="curl-wrap">
19189            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19190  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
19191            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
19192          </div>
19193        </div>
19194      </div>
19195
19196      <div class="ep-card">
19197        <div class="ep-header">
19198          <span class="method get">GET</span>
19199          <span class="ep-path">/api/git/scan-ref</span>
19200          <span class="auth-badge protected">Protected</span>
19201          <span class="ep-desc">SLOC-scan a specific git ref</span>
19202          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19203        </div>
19204        <div class="ep-body">
19205          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
19206          <p class="params-heading">Query Parameters</p>
19207          <table class="params">
19208            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19209            <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>
19210            <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>
19211          </table>
19212          <p class="curl-heading">Example</p>
19213          <div class="curl-wrap">
19214            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19215  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
19216            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
19217          </div>
19218        </div>
19219      </div>
19220
19221      <div class="ep-card">
19222        <div class="ep-header">
19223          <span class="method get">GET</span>
19224          <span class="ep-path">/api/git/compare-refs</span>
19225          <span class="auth-badge protected">Protected</span>
19226          <span class="ep-desc">Compare SLOC across two git refs</span>
19227          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19228        </div>
19229        <div class="ep-body">
19230          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
19231          <p class="params-heading">Query Parameters</p>
19232          <table class="params">
19233            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19234            <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>
19235            <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>
19236            <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>
19237          </table>
19238          <p class="curl-heading">Example</p>
19239          <div class="curl-wrap">
19240            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19241  "<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>
19242            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
19243          </div>
19244        </div>
19245      </div>
19246    </div>
19247
19248    <!-- Webhooks -->
19249    <div class="section">
19250      <h2 class="section-title">Webhooks</h2>
19251      <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>
19252
19253      <div class="ep-card">
19254        <div class="ep-header">
19255          <span class="method post">POST</span>
19256          <span class="ep-path">/webhooks/github</span>
19257          <span class="auth-badge hmac">HMAC</span>
19258          <span class="ep-desc">GitHub push event receiver</span>
19259          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19260        </div>
19261        <div class="ep-body">
19262          <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>
19263          <p class="params-heading">Required Headers</p>
19264          <table class="params">
19265            <tr><th>Header</th><th>Value</th></tr>
19266            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
19267            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
19268            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19269          </table>
19270        </div>
19271      </div>
19272
19273      <div class="ep-card">
19274        <div class="ep-header">
19275          <span class="method post">POST</span>
19276          <span class="ep-path">/webhooks/gitlab</span>
19277          <span class="auth-badge hmac">HMAC</span>
19278          <span class="ep-desc">GitLab push event receiver</span>
19279          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19280        </div>
19281        <div class="ep-body">
19282          <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>
19283          <p class="params-heading">Required Headers</p>
19284          <table class="params">
19285            <tr><th>Header</th><th>Value</th></tr>
19286            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
19287            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
19288            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19289          </table>
19290        </div>
19291      </div>
19292
19293      <div class="ep-card">
19294        <div class="ep-header">
19295          <span class="method post">POST</span>
19296          <span class="ep-path">/webhooks/bitbucket</span>
19297          <span class="auth-badge hmac">HMAC</span>
19298          <span class="ep-desc">Bitbucket push event receiver</span>
19299          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19300        </div>
19301        <div class="ep-body">
19302          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
19303          <p class="params-heading">Required Headers</p>
19304          <table class="params">
19305            <tr><th>Header</th><th>Value</th></tr>
19306            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
19307            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19308          </table>
19309        </div>
19310      </div>
19311    </div>
19312
19313    <!-- Config -->
19314    <div class="section">
19315      <h2 class="section-title">Config Import / Export</h2>
19316
19317      <div class="ep-card">
19318        <div class="ep-header">
19319          <span class="method get">GET</span>
19320          <span class="ep-path">/export-config</span>
19321          <span class="auth-badge protected">Protected</span>
19322          <span class="ep-desc">Export server configuration as JSON</span>
19323          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19324        </div>
19325        <div class="ep-body">
19326          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
19327          <p class="curl-heading">Example</p>
19328          <div class="curl-wrap">
19329            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19330  -o config.json \
19331  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
19332            <button class="curl-copy-btn" data-target="c-export">Copy</button>
19333          </div>
19334        </div>
19335      </div>
19336
19337      <div class="ep-card">
19338        <div class="ep-header">
19339          <span class="method post">POST</span>
19340          <span class="ep-path">/import-config</span>
19341          <span class="auth-badge protected">Protected</span>
19342          <span class="ep-desc">Import server configuration</span>
19343          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19344        </div>
19345        <div class="ep-body">
19346          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
19347          <p class="curl-heading">Example</p>
19348          <div class="curl-wrap">
19349            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
19350  -H "Authorization: Bearer $SLOC_API_KEY" \
19351  -H "Content-Type: application/json" \
19352  -d @config.json \
19353  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
19354            <button class="curl-copy-btn" data-target="c-import">Copy</button>
19355          </div>
19356        </div>
19357      </div>
19358    </div>
19359
19360    <!-- CI Ingest -->
19361    <div class="section">
19362      <h2 class="section-title">CI Ingest</h2>
19363
19364      <div class="ep-card">
19365        <div class="ep-header">
19366          <span class="method post">POST</span>
19367          <span class="ep-path">/api/ingest</span>
19368          <span class="auth-badge protected">Protected</span>
19369          <span class="ep-desc">Push a pre-computed scan result from CI</span>
19370          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19371        </div>
19372        <div class="ep-body">
19373          <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>
19374          <p class="params-heading">Query Parameters</p>
19375          <table class="params">
19376            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19377            <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>
19378          </table>
19379          <p class="params-heading">Request Body (application/json)</p>
19380          <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>
19381          <details class="schema"><summary>Response schema</summary>
19382<div class="schema-block">// 201 Created
19383{
19384  "run_id":   string,  // UUID of the ingested run
19385  "view_url": string   // relative URL to the report page
19386}</div></details>
19387          <p class="curl-heading">Example</p>
19388          <div class="curl-wrap">
19389            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
19390  -H "Authorization: Bearer $SLOC_API_KEY" \
19391  -H "Content-Type: application/json" \
19392  -d @result.json \
19393  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
19394            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
19395          </div>
19396        </div>
19397      </div>
19398    </div>
19399
19400    <!-- Artifact Download -->
19401    <div class="section">
19402      <h2 class="section-title">Artifact Download</h2>
19403
19404      <div class="ep-card">
19405        <div class="ep-header">
19406          <span class="method get">GET</span>
19407          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
19408          <span class="auth-badge protected">Protected</span>
19409          <span class="ep-desc">Download or view a scan artifact</span>
19410          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19411        </div>
19412        <div class="ep-body">
19413          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
19414          <p class="params-heading">Path Parameters</p>
19415          <table class="params">
19416            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19417            <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>
19418            <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>
19419          </table>
19420          <p class="params-heading">Query Parameters</p>
19421          <table class="params">
19422            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19423            <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>
19424          </table>
19425          <p class="curl-heading">Example — download JSON result</p>
19426          <div class="curl-wrap">
19427            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19428  -o result.json \
19429  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
19430            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
19431          </div>
19432        </div>
19433      </div>
19434    </div>
19435
19436    <!-- Embed Widget -->
19437    <div class="section">
19438      <h2 class="section-title">Embed Widget</h2>
19439
19440      <div class="ep-card">
19441        <div class="ep-header">
19442          <span class="method get">GET</span>
19443          <span class="ep-path">/embed/summary</span>
19444          <span class="auth-badge protected">Protected</span>
19445          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
19446          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19447        </div>
19448        <div class="ep-body">
19449          <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>
19450          <p class="params-heading">Query Parameters</p>
19451          <table class="params">
19452            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19453            <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>
19454            <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>
19455          </table>
19456          <p class="curl-heading">Example</p>
19457          <div class="curl-wrap">
19458            <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"
19459        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
19460            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
19461          </div>
19462        </div>
19463      </div>
19464    </div>
19465
19466    <!-- Confluence Integration -->
19467    <div class="section">
19468      <h2 class="section-title">Confluence Integration</h2>
19469
19470      <div class="ep-card">
19471        <div class="ep-header">
19472          <span class="method get">GET</span>
19473          <span class="ep-path">/api/confluence/config</span>
19474          <span class="auth-badge protected">Protected</span>
19475          <span class="ep-desc">Get current Confluence configuration</span>
19476          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19477        </div>
19478        <div class="ep-body">
19479          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
19480          <details class="schema"><summary>Response schema</summary>
19481<div class="schema-block">{
19482  "configured":     boolean,
19483  "tier":           "cloud" | "server",
19484  "base_url":       string,
19485  "username":       string,
19486  "api_token_set":  boolean,
19487  "space_key":      string,
19488  "parent_page_id": string | null,
19489  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
19490}</div></details>
19491          <p class="curl-heading">Example</p>
19492          <div class="curl-wrap">
19493            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19494  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19495            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
19496          </div>
19497        </div>
19498      </div>
19499
19500      <div class="ep-card">
19501        <div class="ep-header">
19502          <span class="method post">POST</span>
19503          <span class="ep-path">/api/confluence/config</span>
19504          <span class="auth-badge protected">Protected</span>
19505          <span class="ep-desc">Save Confluence configuration</span>
19506          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19507        </div>
19508        <div class="ep-body">
19509          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
19510          <p class="params-heading">Request Body (application/json)</p>
19511          <table class="params">
19512            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19513            <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>
19514            <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>
19515            <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>
19516            <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>
19517            <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>
19518            <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>
19519            <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>
19520          </table>
19521          <details class="schema"><summary>Response schema</summary>
19522<div class="schema-block">{ "ok": true }</div></details>
19523          <p class="curl-heading">Example</p>
19524          <div class="curl-wrap">
19525            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
19526  -H "Authorization: Bearer $SLOC_API_KEY" \
19527  -H "Content-Type: application/json" \
19528  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
19529  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19530            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
19531          </div>
19532        </div>
19533      </div>
19534
19535      <div class="ep-card">
19536        <div class="ep-header">
19537          <span class="method post">POST</span>
19538          <span class="ep-path">/api/confluence/test</span>
19539          <span class="auth-badge protected">Protected</span>
19540          <span class="ep-desc">Test Confluence connection</span>
19541          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19542        </div>
19543        <div class="ep-body">
19544          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
19545          <details class="schema"><summary>Response schema</summary>
19546<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
19547          <p class="curl-heading">Example</p>
19548          <div class="curl-wrap">
19549            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
19550  -H "Authorization: Bearer $SLOC_API_KEY" \
19551  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
19552            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
19553          </div>
19554        </div>
19555      </div>
19556
19557      <div class="ep-card">
19558        <div class="ep-header">
19559          <span class="method post">POST</span>
19560          <span class="ep-path">/api/confluence/post</span>
19561          <span class="auth-badge protected">Protected</span>
19562          <span class="ep-desc">Publish a scan report to Confluence</span>
19563          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19564        </div>
19565        <div class="ep-body">
19566          <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>
19567          <p class="params-heading">Request Body (application/json)</p>
19568          <table class="params">
19569            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19570            <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>
19571            <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>
19572            <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>
19573          </table>
19574          <details class="schema"><summary>Response schema</summary>
19575<div class="schema-block">// 200 OK
19576{ "ok": true, "page_id": string }
19577
19578// 400 / 502 on error
19579{ "ok": false, "error": string }</div></details>
19580          <p class="curl-heading">Example</p>
19581          <div class="curl-wrap">
19582            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
19583  -H "Authorization: Bearer $SLOC_API_KEY" \
19584  -H "Content-Type: application/json" \
19585  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
19586  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
19587            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
19588          </div>
19589        </div>
19590      </div>
19591
19592      <div class="ep-card">
19593        <div class="ep-header">
19594          <span class="method get">GET</span>
19595          <span class="ep-path">/api/confluence/wiki-markup</span>
19596          <span class="auth-badge protected">Protected</span>
19597          <span class="ep-desc">Get Confluence wiki markup for a run</span>
19598          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19599        </div>
19600        <div class="ep-body">
19601          <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>
19602          <p class="params-heading">Query Parameters</p>
19603          <table class="params">
19604            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19605            <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>
19606          </table>
19607          <p class="curl-heading">Example</p>
19608          <div class="curl-wrap">
19609            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19610  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
19611            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
19612          </div>
19613        </div>
19614      </div>
19615    </div>
19616
19617    <!-- Authentication -->
19618    <div class="section">
19619      <h2 class="section-title">Authentication</h2>
19620      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
19621
19622      <div class="ep-card">
19623        <div class="ep-header">
19624          <span class="method get">GET</span>
19625          <span class="ep-path">/auth/login</span>
19626          <span class="auth-badge public">Public</span>
19627          <span class="ep-desc">Login page</span>
19628          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19629        </div>
19630        <div class="ep-body">
19631          <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>
19632          <p class="params-heading">Query Parameters</p>
19633          <table class="params">
19634            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19635            <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>
19636            <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>
19637          </table>
19638        </div>
19639      </div>
19640
19641      <div class="ep-card">
19642        <div class="ep-header">
19643          <span class="method post">POST</span>
19644          <span class="ep-path">/auth/login</span>
19645          <span class="auth-badge public">Public</span>
19646          <span class="ep-desc">Submit credentials and get a session cookie</span>
19647          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19648        </div>
19649        <div class="ep-body">
19650          <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>
19651          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
19652          <table class="params">
19653            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19654            <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>
19655            <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>
19656          </table>
19657          <p class="curl-heading">Example</p>
19658          <div class="curl-wrap">
19659            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
19660  -d "key=$SLOC_API_KEY&amp;next=/" \
19661  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
19662            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
19663          </div>
19664        </div>
19665      </div>
19666    </div>
19667
19668    <!-- Coverage Suggestion -->
19669    <div class="section">
19670      <h2 class="section-title">Coverage Suggestion</h2>
19671
19672      <div class="ep-card">
19673        <div class="ep-header">
19674          <span class="method get">GET</span>
19675          <span class="ep-path">/api/suggest-coverage</span>
19676          <span class="auth-badge protected">Protected</span>
19677          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
19678          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19679        </div>
19680        <div class="ep-body">
19681          <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>
19682          <p class="params-heading">Query Parameters</p>
19683          <table class="params">
19684            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19685            <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>
19686          </table>
19687          <details class="schema"><summary>Response schema</summary>
19688<div class="schema-block">{
19689  "found": string | null,  // absolute path to the coverage file, if detected
19690  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
19691  "hint":  string | null   // shell command to generate coverage if not found
19692}</div></details>
19693          <p class="curl-heading">Example</p>
19694          <div class="curl-wrap">
19695            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19696  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
19697            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
19698          </div>
19699        </div>
19700      </div>
19701    </div>
19702
19703  </div>
19704
19705  <footer class="site-footer">
19706    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
19707    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19708    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19709    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19710    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19711  </footer>
19712
19713  <script nonce="{{ csp_nonce }}">
19714    (function () {
19715      var base = window.location.origin;
19716      document.getElementById('base-url').textContent = base;
19717      document.querySelectorAll('.base-url-slot').forEach(function (el) {
19718        el.textContent = base;
19719      });
19720
19721      document.querySelectorAll('.ep-header').forEach(function (hdr) {
19722        hdr.addEventListener('click', function () {
19723          hdr.closest('.ep-card').classList.toggle('open');
19724        });
19725      });
19726
19727      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
19728        btn.addEventListener('click', function () {
19729          var targetId = btn.dataset.target;
19730          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
19731          if (!pre) return;
19732          navigator.clipboard.writeText(pre.textContent).then(function () {
19733            btn.textContent = 'Copied!';
19734            btn.classList.add('copied');
19735            setTimeout(function () {
19736              btn.textContent = 'Copy';
19737              btn.classList.remove('copied');
19738            }, 2000);
19739          });
19740        });
19741      });
19742
19743      var storageKey = 'oxide-sloc-theme';
19744      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
19745      var themeBtn = document.getElementById('theme-toggle');
19746      if (themeBtn) {
19747        themeBtn.addEventListener('click', function () {
19748          var dark = document.body.classList.toggle('dark-theme');
19749          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
19750        });
19751      }
19752      (function() {
19753        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'}];
19754        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);});}
19755        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19756        var btn=document.getElementById('settings-btn');if(!btn)return;
19757        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19758        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>';
19759        document.body.appendChild(m);
19760        var g=document.getElementById('scheme-grid');
19761        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);});
19762        var cl=document.getElementById('settings-close');
19763        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);
19764        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');});
19765        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19766        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19767      })();
19768      (function randomizeWatermarks() {
19769        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19770        if (!wms.length) return;
19771        var placed = [];
19772        function tooClose(top, left) {
19773          for (var i = 0; i < placed.length; i++) {
19774            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19775            if (dt < 16 && dl < 12) return true;
19776          }
19777          return false;
19778        }
19779        function pick(leftBand) {
19780          for (var attempt = 0; attempt < 50; attempt++) {
19781            var top = Math.random() * 88 + 2;
19782            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19783            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19784          }
19785          var top = Math.random() * 88 + 2;
19786          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19787          placed.push([top, left]); return [top, left];
19788        }
19789        var half = Math.floor(wms.length / 2);
19790        wms.forEach(function (img, i) {
19791          var pos = pick(i < half);
19792          var size = Math.floor(Math.random() * 100 + 120);
19793          var rot = (Math.random() * 360).toFixed(1);
19794          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19795          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;
19796        });
19797      })();
19798      (function spawnCodeParticles() {
19799        var container = document.getElementById('code-particles');
19800        if (!container) return;
19801        var snippets = [
19802          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
19803          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
19804          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
19805          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
19806          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
19807        ];
19808        var count = 38;
19809        for (var i = 0; i < count; i++) {
19810          (function(idx) {
19811            var el = document.createElement('span');
19812            el.className = 'code-particle';
19813            el.textContent = snippets[idx % snippets.length];
19814            var left = Math.random() * 94 + 2;
19815            var top = Math.random() * 88 + 6;
19816            var dur = (Math.random() * 10 + 9).toFixed(1);
19817            var delay = (Math.random() * 18).toFixed(1);
19818            var rot = (Math.random() * 26 - 13).toFixed(1);
19819            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19820            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
19821            container.appendChild(el);
19822          })(i);
19823        }
19824      })();
19825    }());
19826  </script>
19827</body>
19828</html>
19829"##,
19830    ext = "html"
19831)]
19832struct ApiDocsTemplate {
19833    has_api_key: bool,
19834    csp_nonce: String,
19835    version: &'static str,
19836}