Skip to main content

sloc_web/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod auth;
26pub(crate) mod confluence;
27pub(crate) mod error;
28pub(crate) mod git_browser;
29pub(crate) mod git_webhook;
30pub(crate) mod integrations;
31
32use std::{
33    collections::{HashMap, VecDeque},
34    fmt::Write,
35    fs,
36    net::{IpAddr, SocketAddr},
37    path::{Path, PathBuf},
38    process::Stdio,
39    sync::{Arc, OnceLock},
40    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
41};
42
43use anyhow::{Context, Result};
44use askama::Template;
45use axum::{
46    body::Body,
47    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
48    http::{header, HeaderValue, Request, StatusCode},
49    middleware::{self, Next},
50    response::{Html, IntoResponse, Response},
51    routing::{get, post},
52    Json, Router,
53};
54use serde::{Deserialize, Serialize};
55use tokio::sync::Mutex;
56use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
57
58use sloc_config::{
59    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
60    MixedLinePolicy,
61};
62use sloc_git::ScheduleStore;
63
64#[derive(Clone)]
65pub(crate) struct CspNonce(pub(crate) String);
66
67static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
68static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
69
70use sloc_core::{
71    analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
72    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
73};
74use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html, write_pdf_from_run};
75const MAX_CONCURRENT_ANALYSES: usize = 4;
76
77/// Windows-only helpers that force the native file-picker dialog into the
78/// foreground instead of appearing minimised behind other windows.
79///
80/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
81/// foreground thread so that windows created on our thread inherit focus; and
82/// (b) spin a polling watcher that finds the dialog by title and calls
83/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
84#[cfg(all(target_os = "windows", feature = "native-dialog"))]
85#[allow(clippy::upper_case_acronyms)]
86mod win_dialog_focus {
87    use std::mem::size_of;
88
89    type HWND = *mut core::ffi::c_void;
90    type DWORD = u32;
91    type UINT = u32;
92    type BOOL = i32;
93
94    // Mirror of FLASHWINFO from winuser.h — field names kept in PascalCase to
95    // match the Win32 ABI layout exactly; the #[allow] suppresses the Rust
96    // naming lint for this one struct.
97    #[repr(C)]
98    #[allow(non_snake_case)]
99    struct FLASHWINFO {
100        cbSize: UINT,
101        hwnd: HWND,
102        dwFlags: DWORD,
103        uCount: UINT,
104        dwTimeout: DWORD,
105    }
106
107    const FLASHW_ALL: DWORD = 0x3;
108    const FLASHW_TIMERNOFG: DWORD = 0xC;
109
110    #[link(name = "user32")]
111    extern "system" {
112        fn GetForegroundWindow() -> HWND;
113        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
114        fn BringWindowToTop(hWnd: HWND) -> BOOL;
115        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
116        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
117        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
118        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
119    }
120
121    #[link(name = "kernel32")]
122    extern "system" {
123        fn GetCurrentThreadId() -> DWORD;
124    }
125
126    /// Attaches our thread's input to the foreground window's thread so that
127    /// windows created on our thread inherit foreground focus.  Returns the
128    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
129    /// the thread was already the foreground thread.
130    pub fn attach_to_foreground() -> DWORD {
131        unsafe {
132            let fg_hwnd = GetForegroundWindow();
133            if fg_hwnd.is_null() {
134                return 0;
135            }
136            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
137            let my_tid = GetCurrentThreadId();
138            if fg_tid == my_tid {
139                return 0;
140            }
141            AttachThreadInput(my_tid, fg_tid, 1);
142            fg_tid
143        }
144    }
145
146    /// Undoes `attach_to_foreground`.
147    pub fn detach_from_foreground(fg_tid: DWORD) {
148        if fg_tid == 0 {
149            return;
150        }
151        unsafe {
152            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
153        }
154    }
155
156    /// Spawns a short-lived watcher thread that polls for a dialog window
157    /// matching `title` and, once found, forces it to the foreground and
158    /// flashes its taskbar button until the user interacts with it.
159    pub fn flash_dialog_when_ready(title: String) {
160        std::thread::spawn(move || {
161            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
162            for _ in 0..40 {
163                std::thread::sleep(std::time::Duration::from_millis(80));
164                unsafe {
165                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
166                    if !hwnd.is_null() {
167                        SetForegroundWindow(hwnd);
168                        BringWindowToTop(hwnd);
169                        #[allow(non_snake_case)]
170                        FlashWindowEx(&FLASHWINFO {
171                            // size_of returns usize; Win32 struct field is u32 (UINT).
172                            // struct size fits trivially within u32.
173                            #[allow(clippy::cast_possible_truncation)]
174                            cbSize: size_of::<FLASHWINFO>() as UINT,
175                            hwnd,
176                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
177                            uCount: 3,
178                            dwTimeout: 0,
179                        });
180                        break;
181                    }
182                }
183            }
184        });
185    }
186}
187
188/// Sliding-window rate limiter keyed by client IP.
189/// Uses only std primitives — no external crate required.
190pub(crate) struct IpRateLimiter {
191    window: Duration,
192    max_requests: usize,
193    pub(crate) auth_lockout_threshold: u32,
194    auth_lockout_window: Duration,
195    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
196    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
197}
198
199impl IpRateLimiter {
200    pub(crate) fn new(
201        window: Duration,
202        max_requests: usize,
203        auth_lockout_threshold: u32,
204        auth_lockout_window: Duration,
205    ) -> Self {
206        Self {
207            window,
208            max_requests,
209            auth_lockout_threshold,
210            auth_lockout_window,
211            state: std::sync::Mutex::new(HashMap::new()),
212            auth_failures: std::sync::Mutex::new(HashMap::new()),
213        }
214    }
215
216    // The MutexGuard `state` must live as long as `bucket` borrows from it,
217    // so it cannot be dropped any earlier than the end of the inner block.
218    #[allow(clippy::significant_drop_tightening)]
219    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
220        let now = Instant::now();
221        let cutoff = now.checked_sub(self.window).unwrap_or(now);
222        let mut state = self
223            .state
224            .lock()
225            .unwrap_or_else(std::sync::PoisonError::into_inner);
226        if state.len() > 10_000 {
227            state.retain(|_, bucket| {
228                while bucket.front().is_some_and(|t| *t <= cutoff) {
229                    bucket.pop_front();
230                }
231                !bucket.is_empty()
232            });
233        }
234        let bucket = state.entry(ip).or_default();
235        while bucket.front().is_some_and(|t| *t <= cutoff) {
236            bucket.pop_front();
237        }
238        if bucket.len() >= self.max_requests {
239            false
240        } else {
241            bucket.push_back(now);
242            true
243        }
244    }
245
246    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
247        let now = Instant::now();
248        let mut map = self
249            .auth_failures
250            .lock()
251            .unwrap_or_else(std::sync::PoisonError::into_inner);
252        map.entry(ip)
253            .and_modify(|e| {
254                e.0 += 1;
255                e.1 = now;
256            })
257            .or_insert_with(|| (1, now));
258    }
259
260    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
261        let mut map = self
262            .auth_failures
263            .lock()
264            .unwrap_or_else(std::sync::PoisonError::into_inner);
265        let expired = map
266            .get(&ip)
267            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
268        if expired {
269            map.remove(&ip);
270            return false;
271        }
272        map.get(&ip)
273            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
274    }
275
276    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
277        let map = self
278            .auth_failures
279            .lock()
280            .unwrap_or_else(std::sync::PoisonError::into_inner);
281        map.get(&ip).map_or(0, |e| {
282            self.auth_lockout_window
283                .checked_sub(e.1.elapsed())
284                .map_or(0, |r| r.as_secs())
285        })
286    }
287
288    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
289        tokio::spawn(async move {
290            let mut interval = tokio::time::interval(Duration::from_mins(1));
291            interval.tick().await; // consume the immediate first tick
292            loop {
293                interval.tick().await;
294                let now = Instant::now();
295                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
296                {
297                    let mut state = limiter
298                        .state
299                        .lock()
300                        .unwrap_or_else(std::sync::PoisonError::into_inner);
301                    state.retain(|_, bucket| {
302                        while bucket.front().is_some_and(|t| *t <= cutoff) {
303                            bucket.pop_front();
304                        }
305                        !bucket.is_empty()
306                    });
307                }
308                {
309                    let mut auth = limiter
310                        .auth_failures
311                        .lock()
312                        .unwrap_or_else(std::sync::PoisonError::into_inner);
313                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
314                }
315            }
316        });
317    }
318}
319
320/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
321/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
322/// files but never triggers a scan.
323fn spawn_upload_staging_cleanup() {
324    tokio::spawn(async move {
325        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
326            .ok()
327            .and_then(|v| v.parse().ok())
328            .unwrap_or(4);
329        let ttl_secs = ttl_hours * 3600;
330        let mut interval = tokio::time::interval(Duration::from_hours(1));
331        interval.tick().await; // consume the immediate first tick
332        loop {
333            interval.tick().await;
334            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
335            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
336                continue;
337            };
338            while let Ok(Some(entry)) = dir.next_entry().await {
339                let path = entry.path();
340                let age_secs = tokio::fs::metadata(&path)
341                    .await
342                    .ok()
343                    .and_then(|m| m.modified().ok())
344                    .and_then(|t| t.elapsed().ok())
345                    .map_or(0, |d| d.as_secs());
346                if age_secs > ttl_secs {
347                    tracing::debug!(
348                        event = "upload_staging_cleanup",
349                        path = %path.display(),
350                        age_secs,
351                        "removing stale upload staging directory"
352                    );
353                    let _ = tokio::fs::remove_dir_all(&path).await;
354                }
355            }
356        }
357    });
358}
359
360/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
361#[derive(Clone, Debug, Default)]
362struct RunResultContext {
363    prev_entry: Option<RegistryEntry>,
364    prev_scan_count: usize,
365    project_path: String,
366}
367
368/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
369#[derive(Clone)]
370enum AsyncRunState {
371    Running {
372        started_at: std::time::Instant,
373        cancel_token: Arc<std::sync::atomic::AtomicBool>,
374        phase: Arc<std::sync::Mutex<String>>,
375        files_done: Arc<std::sync::atomic::AtomicUsize>,
376        files_total: Arc<std::sync::atomic::AtomicUsize>,
377    },
378    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
379    Complete {
380        run_id: String,
381    },
382    Failed {
383        message: String,
384    },
385    Cancelled,
386}
387
388/// A saved scan configuration profile — stores the form parameters so users can
389/// re-run a favourite scan with one click.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391struct ScanProfile {
392    id: String,
393    name: String,
394    created_at: String,
395    /// The raw scan-form parameters serialized as JSON.
396    params: serde_json::Value,
397}
398
399#[derive(Debug, Clone, Default, Serialize, Deserialize)]
400struct ScanProfileStore {
401    profiles: Vec<ScanProfile>,
402}
403
404impl ScanProfileStore {
405    fn load(path: &std::path::Path) -> Self {
406        fs::read_to_string(path)
407            .ok()
408            .and_then(|s| serde_json::from_str(&s).ok())
409            .unwrap_or_default()
410    }
411
412    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
413        if let Some(parent) = path.parent() {
414            fs::create_dir_all(parent)?;
415        }
416        let json = serde_json::to_string_pretty(self)?;
417        fs::write(path, json)?;
418        Ok(())
419    }
420}
421
422#[derive(Clone)]
423pub(crate) struct AppState {
424    pub(crate) base_config: AppConfig,
425    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
426    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
427    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
428    pub(crate) registry_path: PathBuf,
429    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
430    pub(crate) server_mode: bool,
431    pub(crate) tls_enabled: bool,
432    pub(crate) api_keys: Vec<secrecy::Secret<String>>,
433    pub(crate) rate_limiter: Arc<IpRateLimiter>,
434    pub(crate) trust_proxy: bool,
435    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
436    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
437    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
438    /// Directory where remote repositories are cloned for git-browser scans.
439    pub(crate) git_clones_dir: PathBuf,
440    /// Persisted list of webhook / poll schedules.
441    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
442    pub(crate) schedules_path: PathBuf,
443    /// Named scan profiles saved by the user via the web UI.
444    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
445    pub(crate) scan_profiles_path: PathBuf,
446    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
447    /// Persisted Confluence integration settings.
448    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
449    pub(crate) confluence_path: PathBuf,
450    /// Directories the user has pinned for auto-scanning of external reports.
451    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
452    pub(crate) watched_dirs_path: PathBuf,
453}
454
455type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
456
457/// Parameters for the fire-and-forget HTML + PDF background task.
458
459#[derive(Clone, Debug)]
460pub(crate) struct RunArtifacts {
461    output_dir: PathBuf,
462    html_path: Option<PathBuf>,
463    pdf_path: Option<PathBuf>,
464    json_path: Option<PathBuf>,
465    csv_path: Option<PathBuf>,
466    xlsx_path: Option<PathBuf>,
467    scan_config_path: Option<PathBuf>,
468    report_title: String,
469    result_context: RunResultContext,
470}
471
472#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
473fn build_router(state: AppState) -> Router {
474    let protected = Router::new()
475        .route("/", get(splash))
476        .route("/scan-setup", get(scan_setup_handler))
477        .route("/scan", get(index))
478        .route("/analyze", post(analyze_handler))
479        .route("/preview", get(preview_handler))
480        .route("/api/suggest-coverage", get(api_suggest_coverage))
481        .route("/pick-directory", get(pick_directory_handler))
482        .route("/open-path", get(open_path_handler))
483        .route("/pick-file", get(pick_file_handler))
484        .route(
485            "/api/upload-directory",
486            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
487        )
488        .route(
489            "/api/upload-file",
490            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
491        )
492        .route(
493            "/api/upload-tarball",
494            post(upload_tarball_handler).layer(DefaultBodyLimit::disable()),
495        )
496        .route("/locate-report", post(locate_report_handler))
497        .route("/locate-reports-dir", post(locate_reports_dir_handler))
498        .route("/relocate-scan", post(relocate_scan_handler))
499        .route("/watched-dirs/add", post(add_watched_dir_handler))
500        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
501        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
502        .route("/view-reports", get(history_handler))
503        .route("/compare-scans", get(compare_select_handler))
504        .route("/compare", get(compare_handler))
505        .route("/images/{folder}/{file}", get(image_handler))
506        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
507        .route("/api/metrics/latest", get(api_metrics_latest_handler))
508        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
509        .route("/api/metrics/history", get(api_metrics_history_handler))
510        .route(
511            "/api/metrics/submodules",
512            get(api_metrics_submodules_handler),
513        )
514        .route("/api/ingest", post(api_ingest_handler))
515        .route("/api/project-history", get(project_history_handler))
516        .route("/trend-reports", get(trend_report_handler))
517        .route("/test-metrics", get(test_metrics_handler))
518        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
519        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
520        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
521        .route("/runs/result/{run_id}", get(async_run_result_handler))
522        .route("/embed/summary", get(embed_handler))
523        // ── Git browser ────────────────────────────────────────────────────────
524        .route("/git-browser", get(git_browser::git_browser_handler))
525        .route("/api/git/refs", get(git_browser::api_list_refs))
526        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
527        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
528        // ── Config export / import ─────────────────────────────────────────────
529        .route("/export-config", get(export_config_handler))
530        .route("/import-config", post(import_config_handler))
531        // ── Scan profiles ──────────────────────────────────────────────────────
532        .route("/api/scan-profiles", get(api_list_scan_profiles))
533        .route("/api/scan-profiles", post(api_save_scan_profile))
534        .route(
535            "/api/scan-profiles/{id}",
536            axum::routing::delete(api_delete_scan_profile),
537        )
538        // ── Integrations (webhooks + Confluence) ──────────────────────────────
539        .route("/integrations", get(integrations::integrations_handler))
540        .route(
541            "/webhook-setup",
542            get(|| async { axum::response::Redirect::permanent("/integrations") }),
543        )
544        .route(
545            "/confluence-setup",
546            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
547        )
548        .route("/api/schedules", get(git_webhook::api_list_schedules))
549        .route("/api/schedules", post(git_webhook::api_create_schedule))
550        .route(
551            "/api/schedules",
552            axum::routing::delete(git_webhook::api_delete_schedule),
553        )
554        .route(
555            "/api/confluence/config",
556            get(confluence::api_get_confluence_config),
557        )
558        .route(
559            "/api/confluence/config",
560            post(confluence::api_save_confluence_config),
561        )
562        .route(
563            "/api/confluence/test",
564            post(confluence::api_test_confluence),
565        )
566        .route(
567            "/api/confluence/post",
568            post(confluence::api_post_to_confluence),
569        )
570        .route(
571            "/api/confluence/wiki-markup",
572            get(confluence::api_wiki_markup),
573        )
574        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
575        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
576        .route(
577            "/api/runs/{run_id}",
578            axum::routing::delete(delete_run_handler),
579        )
580        .route("/api/runs/cleanup", post(cleanup_runs_handler))
581        // ── REST API reference page ────────────────────────────────────────────
582        .route("/api-docs", get(api_docs_handler))
583        .route_layer(middleware::from_fn_with_state(
584            state.clone(),
585            auth::require_api_key,
586        ));
587
588    protected
589        .route("/healthz", get(healthz))
590        .route("/api/health", get(healthz))
591        .route("/metrics", get(metrics_handler))
592        .route("/api/version", get(api_version_handler))
593        .route("/api/openapi.yaml", get(openapi_yaml_handler))
594        .route("/badge/{metric}", get(badge_handler))
595        .route("/static/chart.js", get(chart_js_handler))
596        .route("/static/chart-report.js", get(report_chart_js_handler))
597        .route("/auth/login", get(auth::auth_login_get))
598        .route("/auth/login", post(auth::auth_login_post))
599        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
600        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
601        .route(
602            "/webhooks/github",
603            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
604        )
605        .route(
606            "/webhooks/gitlab",
607            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
608        )
609        .route(
610            "/webhooks/bitbucket",
611            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
612        )
613        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
614        .layer(middleware::from_fn_with_state(
615            state.clone(),
616            add_security_headers,
617        ))
618        .layer(build_cors_layer(state.server_mode))
619        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
620        .with_state(state)
621}
622
623/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
624pub fn make_test_router() -> Router {
625    let tmp = std::env::temp_dir().join("sloc_test");
626    let state = AppState {
627        base_config: AppConfig::default(),
628        artifacts: Arc::new(Mutex::new(HashMap::new())),
629        async_runs: Arc::new(Mutex::new(HashMap::new())),
630        registry: Arc::new(Mutex::new(ScanRegistry::default())),
631        registry_path: tmp.join("registry.json"),
632        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
633        server_mode: false,
634        tls_enabled: false,
635        api_keys: vec![],
636        rate_limiter: Arc::new(IpRateLimiter::new(
637            Duration::from_mins(1),
638            600,
639            10,
640            Duration::from_hours(1),
641        )),
642        trust_proxy: false,
643        trusted_proxy_ips: vec![],
644        git_clones_dir: tmp.join("git-clones"),
645        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
646        schedules_path: tmp.join("schedules.json"),
647        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
648        scan_profiles_path: tmp.join("scan_profiles.json"),
649        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
650        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
651        confluence_path: tmp.join("confluence_config.json"),
652        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
653        watched_dirs_path: tmp.join("watched_dirs.json"),
654    };
655    build_router(state)
656}
657
658/// Test router with one API key pre-loaded. Used by auth integration tests.
659pub fn make_test_router_with_key(api_key: &str) -> Router {
660    let tmp = std::env::temp_dir().join("sloc_test_key");
661    let state = AppState {
662        base_config: AppConfig::default(),
663        artifacts: Arc::new(Mutex::new(HashMap::new())),
664        async_runs: Arc::new(Mutex::new(HashMap::new())),
665        registry: Arc::new(Mutex::new(ScanRegistry::default())),
666        registry_path: tmp.join("registry.json"),
667        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
668        server_mode: false,
669        tls_enabled: false,
670        api_keys: vec![secrecy::Secret::new(api_key.to_owned())],
671        rate_limiter: Arc::new(IpRateLimiter::new(
672            Duration::from_mins(1),
673            600,
674            10,
675            Duration::from_hours(1),
676        )),
677        trust_proxy: false,
678        trusted_proxy_ips: vec![],
679        git_clones_dir: tmp.join("git-clones"),
680        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
681        schedules_path: tmp.join("schedules.json"),
682        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
683        scan_profiles_path: tmp.join("scan_profiles.json"),
684        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
685        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
686        confluence_path: tmp.join("confluence_config.json"),
687        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
688        watched_dirs_path: tmp.join("watched_dirs.json"),
689    };
690    build_router(state)
691}
692
693struct RuntimeSecurityConfig {
694    api_keys: Vec<secrecy::Secret<String>>,
695    tls_cert: Option<String>,
696    tls_key: Option<String>,
697    tls_enabled: bool,
698    trust_proxy: bool,
699    trusted_proxy_ips: Vec<IpAddr>,
700    rate_limiter: Arc<IpRateLimiter>,
701}
702
703fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
704    let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
705        .or_else(|_| std::env::var("SLOC_API_KEY"))
706        .unwrap_or_default()
707        .split(',')
708        .map(str::trim)
709        .filter(|s| !s.is_empty())
710        .map(|s| secrecy::Secret::new(s.to_owned()))
711        .collect();
712    if server_mode && api_keys.is_empty() {
713        println!(
714            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
715             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
716        );
717    }
718    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
719    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
720    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
721    if server_mode && !tls_enabled {
722        println!(
723            "WARNING: TLS is not configured. Traffic is cleartext. \
724             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
725             or terminate TLS at a reverse proxy (nginx, caddy)."
726        );
727    }
728    if server_mode {
729        println!(
730            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
731             to restrict cross-origin access (comma-separated)."
732        );
733    }
734    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
735    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
736        .unwrap_or_default()
737        .split(',')
738        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
739        .collect();
740    if trust_proxy {
741        if trusted_proxy_ips.is_empty() {
742            println!(
743                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
744                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
745                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
746            );
747        } else {
748            println!(
749                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
750                trusted_proxy_ips
751                    .iter()
752                    .map(std::string::ToString::to_string)
753                    .collect::<Vec<_>>()
754                    .join(", ")
755            );
756        }
757    } else if server_mode {
758        println!(
759            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
760             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
761             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
762             enable per-client rate limiting via X-Forwarded-For."
763        );
764    }
765    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
766        println!(
767            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
768             DISABLED for all git operations. Remove this variable before production use."
769        );
770    }
771    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
772        .ok()
773        .and_then(|v| v.parse::<u32>().ok())
774        .unwrap_or(10);
775    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
776        .ok()
777        .and_then(|v| v.parse::<u64>().ok())
778        .unwrap_or(3600);
779    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
780    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
781    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
782    let default_rpm: usize = if server_mode { 120 } else { 600 };
783    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
784        .ok()
785        .and_then(|v| v.parse::<usize>().ok())
786        .unwrap_or(default_rpm);
787    let rate_limiter = Arc::new(IpRateLimiter::new(
788        Duration::from_mins(1),
789        rate_limit_rpm,
790        auth_lockout_threshold,
791        Duration::from_secs(auth_lockout_secs),
792    ));
793    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
794    RuntimeSecurityConfig {
795        api_keys,
796        tls_cert,
797        tls_key,
798        tls_enabled,
799        trust_proxy,
800        trusted_proxy_ips,
801        rate_limiter,
802    }
803}
804
805/// # Errors
806///
807/// Returns an error if the server fails to bind to the configured address or
808/// if the TLS configuration cannot be loaded.
809///
810/// # Panics
811///
812/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
813#[allow(clippy::too_many_lines)]
814pub async fn serve(config: AppConfig) -> Result<()> {
815    let bind_address = config.web.bind_address.clone();
816    let server_mode = config.web.server_mode;
817    let output_root = resolve_output_root(None);
818    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
819    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
820        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
821    let mut registry = ScanRegistry::load(&registry_path);
822    registry.prune_stale();
823    let _ = registry.save(&registry_path);
824
825    let sec = load_runtime_security_config(server_mode);
826    spawn_upload_staging_cleanup();
827
828    let git_clones_dir = resolve_git_clones_dir(&output_root);
829    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
830        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
831    let schedules = ScheduleStore::load(&schedules_path);
832    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
833        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
834    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
835    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
836        |_| output_root.join("confluence_config.json"),
837        PathBuf::from,
838    );
839    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
840    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
841        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
842    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
843
844    let state = AppState {
845        base_config: config,
846        artifacts: Arc::new(Mutex::new(HashMap::new())),
847        async_runs: Arc::new(Mutex::new(HashMap::new())),
848        registry: Arc::new(Mutex::new(registry)),
849        registry_path,
850        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
851        server_mode,
852        tls_enabled: sec.tls_enabled,
853        api_keys: sec.api_keys,
854        rate_limiter: sec.rate_limiter,
855        trust_proxy: sec.trust_proxy,
856        trusted_proxy_ips: sec.trusted_proxy_ips,
857        git_clones_dir,
858        schedules: Arc::new(Mutex::new(schedules)),
859        schedules_path,
860        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
861        scan_profiles_path,
862        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
863        confluence: Arc::new(Mutex::new(confluence)),
864        confluence_path,
865        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
866        watched_dirs_path,
867    };
868
869    restart_poll_schedules(&state).await;
870
871    let app = build_router(state.clone());
872
873    // Try the configured port first, then step up through a few alternatives.
874    // On Windows, a killed process can leave its LISTEN socket as an unkillable
875    // kernel zombie (visible in netstat but owned by no living process).  Rather
876    // than failing, we auto-select the next free port and tell the user.
877    let preferred: SocketAddr = bind_address
878        .parse()
879        .with_context(|| format!("invalid bind address: {bind_address}"))?;
880    let (listener, addr) = {
881        let candidates = (0u16..=9).map(|offset| {
882            let mut a = preferred;
883            a.set_port(preferred.port().saturating_add(offset));
884            a
885        });
886        let mut found = None;
887        for candidate in candidates {
888            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
889                found = Some((l, candidate));
890                break;
891            }
892        }
893        found.ok_or_else(|| {
894            anyhow::anyhow!(
895                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
896                bind_address,
897                preferred.port(),
898                preferred.port().saturating_add(9)
899            )
900        })?
901    };
902    if addr != preferred {
903        eprintln!(
904            "NOTE: port {} is blocked by a system socket (Windows zombie); \
905             using {} instead.",
906            preferred.port(),
907            addr.port()
908        );
909    }
910
911    if sec.tls_enabled {
912        let cert_path = sec
913            .tls_cert
914            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
915        let key_path = sec
916            .tls_key
917            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
918        let tls_config = build_tls_config(&cert_path, &key_path)
919            .context("failed to load TLS certificate/key")?;
920        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
921
922        let url = format!("https://{addr}/");
923        println!("OxideSLOC server running at {url} (TLS)");
924        println!("Use Ctrl+C to stop.");
925
926        return serve_tls(listener, app, acceptor, server_mode).await;
927    }
928
929    let url = format!("http://{addr}/");
930    log_startup_url(&url, server_mode);
931
932    axum::serve(
933        listener,
934        app.into_make_service_with_connect_info::<SocketAddr>(),
935    )
936    .with_graceful_shutdown(shutdown_signal(server_mode))
937    .await
938    .context("web server terminated unexpectedly")
939}
940
941/// Discover the primary non-loopback IPv4 address by asking the OS which
942/// outbound interface it would use to reach a public address.  No packets are
943/// sent — the UDP socket is only used to query the routing table.
944fn primary_lan_ip() -> Option<String> {
945    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
946    socket.connect("8.8.8.8:80").ok()?;
947    let addr = socket.local_addr().ok()?;
948    let ip = addr.ip();
949    if ip.is_loopback() {
950        return None;
951    }
952    Some(ip.to_string())
953}
954
955/// Print the startup URL and, in local mode, open the browser and schedule it.
956fn log_startup_url(url: &str, server_mode: bool) {
957    if server_mode {
958        println!("OxideSLOC server running at {url}");
959        println!("Use Ctrl+C to stop.");
960    } else {
961        println!("OxideSLOC local web UI running at {url}");
962        println!("Press Ctrl+C to stop the server.");
963        let open_url = url.to_owned();
964        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
965    }
966}
967
968/// Open the given URL in the default system browser.
969fn open_browser_tab(url: &str) {
970    #[cfg(target_os = "windows")]
971    let _ = std::process::Command::new("cmd")
972        .args(["/c", "start", "", url])
973        .stdout(Stdio::null())
974        .stderr(Stdio::null())
975        .spawn();
976    #[cfg(target_os = "macos")]
977    let _ = std::process::Command::new("open")
978        .arg(url)
979        .stdout(Stdio::null())
980        .stderr(Stdio::null())
981        .spawn();
982    #[cfg(target_os = "linux")]
983    let _ = std::process::Command::new("xdg-open")
984        .arg(url)
985        .stdout(Stdio::null())
986        .stderr(Stdio::null())
987        .spawn();
988}
989
990/// Graceful-shutdown future: resolves on Ctrl-C.
991async fn shutdown_signal(server_mode: bool) {
992    if tokio::signal::ctrl_c().await.is_ok() {
993        println!();
994        if server_mode {
995            println!("Shutting down OxideSLOC server...");
996        } else {
997            println!("Shutting down OxideSLOC local web UI...");
998        }
999        println!("Server stopped cleanly.");
1000    }
1001}
1002
1003/// Load a rustls `ServerConfig` from PEM certificate and key files.
1004fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1005    use rustls_pemfile::{certs, private_key};
1006    use std::io::BufReader;
1007
1008    let cert_bytes =
1009        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1010    let key_bytes =
1011        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1012
1013    let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
1014        .collect::<std::result::Result<_, _>>()
1015        .context("failed to parse TLS certificates")?;
1016
1017    let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
1018        .context("failed to parse TLS private key")?
1019        .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
1020
1021    rustls::ServerConfig::builder()
1022        .with_no_client_auth()
1023        .with_single_cert(cert_chain, key)
1024        .context("failed to build TLS server config")
1025}
1026
1027/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1028async fn serve_tls(
1029    listener: tokio::net::TcpListener,
1030    app: Router,
1031    acceptor: tokio_rustls::TlsAcceptor,
1032    server_mode: bool,
1033) -> Result<()> {
1034    use hyper_util::rt::{TokioExecutor, TokioIo};
1035    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1036    use hyper_util::service::TowerToHyperService;
1037    use tower::{Service, ServiceExt};
1038
1039    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1040
1041    loop {
1042        tokio::select! {
1043            biased;
1044            _ = tokio::signal::ctrl_c() => {
1045                println!();
1046                if server_mode {
1047                    println!("Shutting down OxideSLOC server...");
1048                } else {
1049                    println!("Shutting down OxideSLOC local web UI...");
1050                }
1051                println!("Server stopped cleanly.");
1052                return Ok(());
1053            }
1054            result = listener.accept() => {
1055                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1056                let acceptor = acceptor.clone();
1057                let mut factory = make_svc.clone();
1058
1059                tokio::spawn(async move {
1060                    let tls = match acceptor.accept(tcp).await {
1061                        Ok(s) => s,
1062                        Err(e) => {
1063                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1064                            return;
1065                        }
1066                    };
1067                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1068                        Ok(f) => match Service::call(f, peer_addr).await {
1069                            Ok(s) => s,
1070                            Err(_) => return,
1071                        },
1072                        Err(_) => return,
1073                    };
1074                    let io = TokioIo::new(tls);
1075                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1076                        .serve_connection(io, TowerToHyperService::new(svc))
1077                        .await
1078                    {
1079                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1080                    }
1081                });
1082            }
1083        }
1084    }
1085}
1086
1087// auth moved to auth.rs
1088
1089fn build_cors_layer(server_mode: bool) -> CorsLayer {
1090    if server_mode {
1091        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1092            .unwrap_or_default()
1093            .split(',')
1094            .filter(|s| !s.is_empty())
1095            .filter_map(|s| s.trim().parse().ok())
1096            .collect();
1097        if allowed.is_empty() {
1098            return CorsLayer::new();
1099        }
1100        CorsLayer::new()
1101            .allow_origin(AllowOrigin::list(allowed))
1102            .allow_methods(AllowMethods::list([
1103                axum::http::Method::GET,
1104                axum::http::Method::POST,
1105            ]))
1106            .allow_headers(AllowHeaders::list([
1107                axum::http::header::AUTHORIZATION,
1108                axum::http::header::CONTENT_TYPE,
1109            ]))
1110    } else {
1111        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1112            let s = origin.to_str().unwrap_or("");
1113            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1114        }))
1115    }
1116}
1117
1118async fn add_security_headers(
1119    State(state): State<AppState>,
1120    mut req: Request<Body>,
1121    next: Next,
1122) -> Response {
1123    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1124    req.extensions_mut().insert(CspNonce(nonce.clone()));
1125    let mut resp = next.run(req).await;
1126    let h = resp.headers_mut();
1127    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1128    h.insert(
1129        "X-Content-Type-Options",
1130        HeaderValue::from_static("nosniff"),
1131    );
1132    h.insert(
1133        "Referrer-Policy",
1134        HeaderValue::from_static("strict-origin-when-cross-origin"),
1135    );
1136    let csp = format!(
1137        "default-src 'self'; \
1138         style-src 'self' 'unsafe-inline'; \
1139         img-src 'self' data: blob:; \
1140         script-src 'self' 'nonce-{nonce}'; \
1141         font-src 'self' data:; \
1142         object-src 'none'; \
1143         frame-ancestors 'none'"
1144    );
1145    h.insert(
1146        "Content-Security-Policy",
1147        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1148            HeaderValue::from_static(
1149                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1150            )
1151        }),
1152    );
1153    h.insert(
1154        "X-Permitted-Cross-Domain-Policies",
1155        HeaderValue::from_static("none"),
1156    );
1157    h.insert(
1158        "Permissions-Policy",
1159        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1160    );
1161    h.insert(
1162        "Cross-Origin-Opener-Policy",
1163        HeaderValue::from_static("same-origin"),
1164    );
1165    h.insert(
1166        "Cross-Origin-Resource-Policy",
1167        HeaderValue::from_static("same-origin"),
1168    );
1169    if state.tls_enabled {
1170        h.insert(
1171            "Strict-Transport-Security",
1172            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1173        );
1174    }
1175    resp
1176}
1177
1178async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1179    let peer_ip = req
1180        .extensions()
1181        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1182        .map(|c| c.0.ip());
1183
1184    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1185    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1186    // header spoofing from direct connections.
1187    let ip = peer_ip
1188        .and_then(|peer| {
1189            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1190                req.headers()
1191                    .get("X-Forwarded-For")
1192                    .and_then(|v| v.to_str().ok())
1193                    .and_then(|s| s.split(',').next())
1194                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1195            } else {
1196                None
1197            }
1198        })
1199        .or(peer_ip)
1200        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1201
1202    if !state.rate_limiter.is_allowed(ip) {
1203        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1204            path = %req.uri().path(), "Rate limit exceeded");
1205        return (
1206            StatusCode::TOO_MANY_REQUESTS,
1207            [(header::RETRY_AFTER, "60")],
1208            "429 Too Many Requests\n",
1209        )
1210            .into_response();
1211    }
1212    next.run(req).await
1213}
1214
1215async fn splash(
1216    State(state): State<AppState>,
1217    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1218) -> impl IntoResponse {
1219    let lan_ip = if state.server_mode {
1220        primary_lan_ip()
1221    } else {
1222        None
1223    };
1224    let port = state
1225        .base_config
1226        .web
1227        .bind_address
1228        .rsplit(':')
1229        .next()
1230        .and_then(|p| p.parse::<u16>().ok())
1231        .unwrap_or(4317);
1232    let has_api_key = !state.api_keys.is_empty();
1233    let template = SplashTemplate {
1234        csp_nonce,
1235        server_mode: state.server_mode,
1236        lan_ip,
1237        port,
1238        version: env!("CARGO_PKG_VERSION"),
1239        has_api_key,
1240    };
1241    Html(
1242        template
1243            .render()
1244            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1245    )
1246}
1247
1248async fn index(
1249    State(state): State<AppState>,
1250    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1251    Query(query): Query<IndexQuery>,
1252) -> impl IntoResponse {
1253    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1254        let policy = query
1255            .mixed_line_policy
1256            .unwrap_or_else(|| "code_only".to_string());
1257        let behavior = query
1258            .binary_file_behavior
1259            .unwrap_or_else(|| "skip".to_string());
1260        let cfg = ScanConfig {
1261            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1262            path: query.path.unwrap_or_default(),
1263            include_globs: query.include_globs.unwrap_or_default(),
1264            exclude_globs: query.exclude_globs.unwrap_or_default(),
1265            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1266            mixed_line_policy: policy,
1267            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1268                != Some("off"),
1269            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1270            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1271            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1272                != Some("disabled"),
1273            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1274            binary_file_behavior: behavior,
1275            output_dir: query.output_dir.unwrap_or_default(),
1276            report_title: query.report_title.unwrap_or_default(),
1277            generate_html: query.generate_html.as_deref() != Some("off"),
1278            generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1279        };
1280        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1281    } else {
1282        "{}".to_string()
1283    };
1284
1285    let git_repo = query.git_repo.unwrap_or_default();
1286    let git_ref = query.git_ref.unwrap_or_default();
1287
1288    let git_label = make_git_label(&git_repo, &git_ref);
1289    let git_output_dir = if git_label.is_empty() {
1290        String::new()
1291    } else {
1292        desktop_dir().join(&git_label).display().to_string()
1293    };
1294    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1295    let git_output_dir_json =
1296        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1297
1298    let template = IndexTemplate {
1299        version: env!("CARGO_PKG_VERSION"),
1300        prefill_json,
1301        csp_nonce,
1302        git_repo,
1303        git_ref,
1304        git_label_json,
1305        git_output_dir_json,
1306        server_mode: state.server_mode,
1307    };
1308
1309    Html(
1310        template
1311            .render()
1312            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1313    )
1314}
1315
1316async fn scan_setup_handler(
1317    State(state): State<AppState>,
1318    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1319) -> impl IntoResponse {
1320    let recent_scans_json = {
1321        let arr: Vec<serde_json::Value> = {
1322            let reg = state.registry.lock().await;
1323            reg.entries
1324                .iter()
1325                .rev()
1326                .take(6)
1327                .map(|e| {
1328                    let run_dir = e
1329                        .html_path
1330                        .as_ref()
1331                        .or(e.json_path.as_ref())
1332                        .and_then(|p| p.parent().map(PathBuf::from));
1333                    let config_val: Option<serde_json::Value> = run_dir
1334                        .and_then(|d| find_scan_config_in_dir(&d))
1335                        .and_then(|p| fs::read_to_string(&p).ok())
1336                        .and_then(|s| serde_json::from_str(&s).ok());
1337                    serde_json::json!({
1338                        "project_label": e.project_label,
1339                        "timestamp": fmt_la_time(e.timestamp_utc),
1340                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1341                        "config": config_val,
1342                    })
1343                })
1344                .collect()
1345        };
1346        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1347    };
1348
1349    let template = ScanSetupTemplate {
1350        version: env!("CARGO_PKG_VERSION"),
1351        recent_scans_json,
1352        csp_nonce,
1353    };
1354    Html(
1355        template
1356            .render()
1357            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1358    )
1359}
1360
1361async fn healthz() -> &'static str {
1362    "ok"
1363}
1364
1365async fn api_version_handler() -> impl IntoResponse {
1366    axum::Json(serde_json::json!({
1367        "name": "oxide-sloc",
1368        "version": env!("CARGO_PKG_VERSION"),
1369    }))
1370}
1371
1372// ── Prometheus metrics ────────────────────────────────────────────────────────
1373
1374fn prom_runs_total() -> &'static prometheus::IntCounter {
1375    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
1376    COUNTER.get_or_init(|| {
1377        prometheus::register_int_counter!(
1378            "oxide_sloc_runs_total",
1379            "Total number of completed analysis runs"
1380        )
1381        .expect("failed to register oxide_sloc_runs_total counter")
1382    })
1383}
1384
1385async fn metrics_handler() -> impl IntoResponse {
1386    use prometheus::Encoder as _;
1387    let mut buf = Vec::new();
1388    let encoder = prometheus::TextEncoder::new();
1389    let _ = encoder.encode(&prometheus::gather(), &mut buf);
1390    (
1391        [(
1392            axum::http::header::CONTENT_TYPE,
1393            "text/plain; version=0.0.4; charset=utf-8",
1394        )],
1395        buf,
1396    )
1397}
1398
1399static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1400
1401async fn openapi_yaml_handler() -> impl IntoResponse {
1402    (
1403        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1404        OPENAPI_YAML,
1405    )
1406}
1407
1408async fn api_docs_handler(
1409    State(state): State<AppState>,
1410    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1411) -> impl IntoResponse {
1412    let has_api_key = !state.api_keys.is_empty();
1413    Html(
1414        ApiDocsTemplate {
1415            has_api_key,
1416            csp_nonce,
1417            version: env!("CARGO_PKG_VERSION"),
1418        }
1419        .render()
1420        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1421    )
1422}
1423
1424async fn chart_js_handler() -> impl IntoResponse {
1425    (
1426        [
1427            (
1428                header::CONTENT_TYPE,
1429                "application/javascript; charset=utf-8",
1430            ),
1431            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1432        ],
1433        CHART_JS,
1434    )
1435}
1436
1437async fn report_chart_js_handler() -> impl IntoResponse {
1438    (
1439        [
1440            (
1441                header::CONTENT_TYPE,
1442                "application/javascript; charset=utf-8",
1443            ),
1444            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1445        ],
1446        REPORT_CHART_JS,
1447    )
1448}
1449
1450#[derive(Debug, Deserialize)]
1451struct AnalyzeForm {
1452    path: String,
1453    git_repo: Option<String>,
1454    git_ref: Option<String>,
1455    mixed_line_policy: Option<MixedLinePolicy>,
1456    python_docstrings_as_comments: Option<String>,
1457    generated_file_detection: Option<String>,
1458    minified_file_detection: Option<String>,
1459    vendor_directory_detection: Option<String>,
1460    include_lockfiles: Option<String>,
1461    binary_file_behavior: Option<BinaryFileBehavior>,
1462    output_dir: Option<String>,
1463    report_title: Option<String>,
1464    report_header_footer: Option<String>,
1465    generate_html: Option<String>,
1466    generate_pdf: Option<String>,
1467    include_globs: Option<String>,
1468    exclude_globs: Option<String>,
1469    submodule_breakdown: Option<String>,
1470    coverage_file: Option<String>,
1471    continuation_line_policy: Option<ContinuationLinePolicy>,
1472    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1473    count_compiler_directives: Option<String>,
1474}
1475
1476#[allow(clippy::struct_excessive_bools)]
1477#[derive(Debug, Serialize, Deserialize, Clone)]
1478struct ScanConfig {
1479    oxide_sloc_version: String,
1480    path: String,
1481    include_globs: String,
1482    exclude_globs: String,
1483    submodule_breakdown: bool,
1484    mixed_line_policy: String,
1485    python_docstrings_as_comments: bool,
1486    generated_file_detection: bool,
1487    minified_file_detection: bool,
1488    vendor_directory_detection: bool,
1489    include_lockfiles: bool,
1490    binary_file_behavior: String,
1491    output_dir: String,
1492    report_title: String,
1493    generate_html: bool,
1494    generate_pdf: bool,
1495}
1496
1497#[derive(Debug, Deserialize, Default)]
1498struct IndexQuery {
1499    path: Option<String>,
1500    include_globs: Option<String>,
1501    exclude_globs: Option<String>,
1502    submodule_breakdown: Option<String>,
1503    mixed_line_policy: Option<String>,
1504    python_docstrings_as_comments: Option<String>,
1505    generated_file_detection: Option<String>,
1506    minified_file_detection: Option<String>,
1507    vendor_directory_detection: Option<String>,
1508    include_lockfiles: Option<String>,
1509    binary_file_behavior: Option<String>,
1510    output_dir: Option<String>,
1511    report_title: Option<String>,
1512    generate_html: Option<String>,
1513    generate_pdf: Option<String>,
1514    prefilled: Option<String>,
1515    git_repo: Option<String>,
1516    git_ref: Option<String>,
1517}
1518
1519#[derive(Debug, Deserialize)]
1520struct PreviewQuery {
1521    path: Option<String>,
1522    include_globs: Option<String>,
1523    exclude_globs: Option<String>,
1524}
1525
1526#[cfg(feature = "native-dialog")]
1527#[derive(Debug, Deserialize)]
1528struct PickDirectoryQuery {
1529    kind: Option<String>,
1530    current: Option<String>,
1531}
1532
1533#[cfg(not(feature = "native-dialog"))]
1534#[derive(Debug, Deserialize)]
1535struct PickDirectoryQuery {}
1536
1537#[derive(Debug, Deserialize, Default)]
1538struct ArtifactQuery {
1539    download: Option<String>,
1540}
1541
1542#[cfg(feature = "native-dialog")]
1543#[derive(Debug, Serialize)]
1544struct PickDirectoryResponse {
1545    selected_path: Option<String>,
1546    cancelled: bool,
1547}
1548
1549#[cfg(feature = "native-dialog")]
1550async fn pick_directory_handler(
1551    State(state): State<AppState>,
1552    Query(query): Query<PickDirectoryQuery>,
1553) -> Response {
1554    if state.server_mode {
1555        return StatusCode::NOT_FOUND.into_response();
1556    }
1557
1558    let is_coverage = query.kind.as_deref() == Some("coverage");
1559    let title = match query.kind.as_deref() {
1560        Some("output") => "Select output directory",
1561        Some("reports") => "Select folder containing saved reports",
1562        Some("coverage") => "Select LCOV coverage file",
1563        _ => "Select project directory",
1564    }
1565    .to_owned();
1566    let current = query.current.clone();
1567
1568    let picked = tokio::task::spawn_blocking(move || {
1569        // Windows: attach to the foreground thread so the dialog inherits focus,
1570        // and kick off a watcher that flashes the dialog once it appears.
1571        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1572        let fg_tid = win_dialog_focus::attach_to_foreground();
1573        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1574        win_dialog_focus::flash_dialog_when_ready(title.clone());
1575
1576        let mut dialog = rfd::FileDialog::new().set_title(&title);
1577        if let Some(current) = current.as_deref() {
1578            let resolved = resolve_input_path(current);
1579            let seed = if resolved.is_dir() {
1580                Some(resolved)
1581            } else {
1582                resolved.parent().map(Path::to_path_buf)
1583            };
1584            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1585                dialog = dialog.set_directory(seed_dir);
1586            }
1587        }
1588        let result = if is_coverage {
1589            dialog
1590                .add_filter(
1591                    "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1592                    &["info", "lcov", "xml"],
1593                )
1594                .pick_file()
1595        } else {
1596            dialog.pick_folder()
1597        };
1598
1599        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1600        win_dialog_focus::detach_from_foreground(fg_tid);
1601
1602        result
1603    })
1604    .await
1605    .unwrap_or(None);
1606
1607    Json(PickDirectoryResponse {
1608        selected_path: picked.as_ref().map(|p| display_path(p)),
1609        cancelled: picked.is_none(),
1610    })
1611    .into_response()
1612}
1613
1614#[cfg(not(feature = "native-dialog"))]
1615async fn pick_directory_handler(
1616    State(_state): State<AppState>,
1617    Query(_query): Query<PickDirectoryQuery>,
1618) -> Response {
1619    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1620}
1621
1622#[cfg(feature = "native-dialog")]
1623async fn pick_file_handler(State(state): State<AppState>) -> Response {
1624    if state.server_mode {
1625        return StatusCode::NOT_FOUND.into_response();
1626    }
1627    let picked = tokio::task::spawn_blocking(|| {
1628        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1629        let fg_tid = win_dialog_focus::attach_to_foreground();
1630        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1631        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1632
1633        let result = rfd::FileDialog::new()
1634            .set_title("Select HTML report")
1635            .add_filter("HTML report", &["html"])
1636            .pick_file();
1637
1638        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1639        win_dialog_focus::detach_from_foreground(fg_tid);
1640
1641        result
1642    })
1643    .await
1644    .unwrap_or(None);
1645    Json(PickDirectoryResponse {
1646        selected_path: picked.as_ref().map(|p| display_path(p)),
1647        cancelled: picked.is_none(),
1648    })
1649    .into_response()
1650}
1651
1652#[cfg(not(feature = "native-dialog"))]
1653async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1654    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1655}
1656
1657// ── Browser-upload handlers (server mode only) ────────────────────────────────
1658
1659/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
1660/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
1661fn is_upload_tmp_path(path: &Path) -> bool {
1662    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
1663    path.starts_with(&upload_root)
1664}
1665
1666/// Returns true when `path` is the built-in sample or test-fixture directory.
1667/// These paths ship with the server binary and are always safe to scan/preview.
1668fn is_sample_path(path: &Path) -> bool {
1669    let root = workspace_root();
1670    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
1671}
1672
1673/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
1674fn upload_base_dir() -> PathBuf {
1675    std::env::temp_dir().join("oxide-sloc-uploads")
1676}
1677
1678/// Returns the staging path for a given upload id inside the base dir.
1679fn upload_staging_path(id: &str) -> PathBuf {
1680    upload_base_dir().join(id)
1681}
1682
1683/// Validate basic field constraints on a directory-upload request.
1684/// Returns an error `Response` if the request should be rejected immediately.
1685#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
1686fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
1687    const MAX_FILES: usize = 50_000;
1688    if body.files.is_empty() {
1689        return Err((
1690            StatusCode::BAD_REQUEST,
1691            Json(serde_json::json!({"error": "No files received"})),
1692        )
1693            .into_response());
1694    }
1695    if body.files.len() > MAX_FILES {
1696        return Err((
1697            StatusCode::PAYLOAD_TOO_LARGE,
1698            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
1699        )
1700            .into_response());
1701    }
1702    Ok(())
1703}
1704
1705/// Resolve or create the staging directory for a directory upload.
1706/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
1707fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
1708    match id {
1709        Some(id)
1710            if !id.is_empty()
1711                && id.len() <= 36
1712                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
1713        {
1714            (id.to_string(), upload_staging_path(id))
1715        }
1716        _ => {
1717            let new_id = uuid::Uuid::new_v4().to_string();
1718            let staging = upload_staging_path(&new_id);
1719            (new_id, staging)
1720        }
1721    }
1722}
1723
1724/// Decode, size-check, and write one uploaded file entry into `staging`.
1725/// Returns `Ok(())` whether the file was written or skipped (bad base64).
1726/// Returns `Err(Response)` for fatal errors; the caller is responsible for
1727/// cleaning up `staging` before propagating the error.
1728#[allow(clippy::result_large_err)]
1729async fn stage_decoded_entry(
1730    entry: &UploadedFile,
1731    staging: &Path,
1732    total_bytes: &mut usize,
1733    project_root: &mut Option<PathBuf>,
1734) -> Result<(), Response> {
1735    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
1736
1737    let Ok(data) = base64::Engine::decode(
1738        &base64::engine::general_purpose::STANDARD,
1739        entry.content.as_bytes(),
1740    ) else {
1741        return Ok(());
1742    };
1743
1744    *total_bytes += data.len();
1745    if *total_bytes > MAX_TOTAL_BYTES {
1746        return Err((
1747            StatusCode::PAYLOAD_TOO_LARGE,
1748            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
1749        )
1750            .into_response());
1751    }
1752
1753    let rel = std::path::Path::new(&entry.path);
1754    if project_root.is_none() {
1755        if let Some(first) = rel.components().next() {
1756            *project_root = Some(staging.join(first.as_os_str()));
1757        }
1758    }
1759
1760    let dest = staging.join(rel);
1761    if let Some(parent) = dest.parent() {
1762        if tokio::fs::create_dir_all(parent).await.is_err() {
1763            return Err((
1764                StatusCode::INTERNAL_SERVER_ERROR,
1765                Json(serde_json::json!({"error": "Failed to create directory structure"})),
1766            )
1767                .into_response());
1768        }
1769    }
1770
1771    if tokio::fs::write(&dest, &data).await.is_err() {
1772        return Err((
1773            StatusCode::INTERNAL_SERVER_ERROR,
1774            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
1775        )
1776            .into_response());
1777    }
1778
1779    Ok(())
1780}
1781
1782/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
1783/// and path-traversal guard. Returns `(file_count, project_root)` on success or
1784/// an error `Response` on failure (staging dir is cleaned up before returning).
1785async fn write_upload_files(
1786    files: &[UploadedFile],
1787    staging: &Path,
1788    upload_id: &str,
1789) -> Result<(usize, Option<PathBuf>), Response> {
1790    let mut total_bytes: usize = 0;
1791    let mut project_root: Option<PathBuf> = None;
1792    let mut traversal_attempts: usize = 0;
1793
1794    for entry in files {
1795        let rel = std::path::Path::new(&entry.path);
1796        if rel
1797            .components()
1798            .any(|c| matches!(c, std::path::Component::ParentDir))
1799        {
1800            traversal_attempts += 1;
1801            if traversal_attempts >= 5 {
1802                let _ = tokio::fs::remove_dir_all(staging).await;
1803                tracing::warn!(
1804                    event = "upload_path_traversal",
1805                    upload_id = %upload_id,
1806                    "Upload rejected: repeated path traversal attempts detected"
1807                );
1808                return Err((
1809                    StatusCode::BAD_REQUEST,
1810                    Json(serde_json::json!({"error": "Upload rejected"})),
1811                )
1812                    .into_response());
1813            }
1814            continue;
1815        }
1816
1817        if let Err(resp) =
1818            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
1819        {
1820            let _ = tokio::fs::remove_dir_all(staging).await;
1821            return Err(resp);
1822        }
1823    }
1824
1825    Ok((files.len(), project_root))
1826}
1827
1828/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
1829/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
1830fn parse_tarball_size_caps() -> (u64, u64) {
1831    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
1832        .ok()
1833        .and_then(|v| v.parse().ok())
1834        .unwrap_or(2048_u64)
1835        * 1024
1836        * 1024;
1837    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
1838        .ok()
1839        .and_then(|v| v.parse().ok())
1840        .unwrap_or(10_240_u64)
1841        * 1024
1842        * 1024;
1843    (compressed, decompressed)
1844}
1845
1846/// Stream `body` into `dest_path`, enforcing `max_bytes`.
1847/// Returns the number of compressed bytes written, or an error `Response`.
1848/// Cleans up `dest_path` on error.
1849#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
1850async fn stream_body_to_file(
1851    body: axum::body::Body,
1852    dest_path: &Path,
1853    max_bytes: u64,
1854) -> Result<u64, Response> {
1855    use http_body_util::BodyExt as _;
1856    use tokio::io::AsyncWriteExt as _;
1857
1858    let mut file = match tokio::fs::File::create(dest_path).await {
1859        Ok(f) => f,
1860        Err(e) => {
1861            tracing::error!(
1862                event = "upload_io_error",
1863                "failed to create tarball temp file: {e}"
1864            );
1865            return Err((
1866                StatusCode::INTERNAL_SERVER_ERROR,
1867                Json(serde_json::json!({"error": "Upload initialization failed"})),
1868            )
1869                .into_response());
1870        }
1871    };
1872
1873    let mut body = body;
1874    let mut written: u64 = 0;
1875    loop {
1876        match body.frame().await {
1877            None => break,
1878            Some(Err(e)) => {
1879                let _ = tokio::fs::remove_file(dest_path).await;
1880                return Err((
1881                    StatusCode::BAD_REQUEST,
1882                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
1883                )
1884                    .into_response());
1885            }
1886            Some(Ok(frame)) => {
1887                if let Ok(data) = frame.into_data() {
1888                    written += data.len() as u64;
1889                    if written > max_bytes {
1890                        let _ = tokio::fs::remove_file(dest_path).await;
1891                        return Err((
1892                            StatusCode::PAYLOAD_TOO_LARGE,
1893                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
1894                        )
1895                            .into_response());
1896                    }
1897                    if let Err(e) = file.write_all(&data).await {
1898                        let _ = tokio::fs::remove_file(dest_path).await;
1899                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
1900                        return Err((
1901                            StatusCode::INTERNAL_SERVER_ERROR,
1902                            Json(serde_json::json!({"error": "Upload write failed"})),
1903                        )
1904                            .into_response());
1905                    }
1906                }
1907            }
1908        }
1909    }
1910    drop(file);
1911    Ok(written)
1912}
1913
1914/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
1915/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
1916/// on failure (staging dir is cleaned up before returning).
1917#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
1918async fn extract_tarball_to_staging(
1919    tarball_path: &Path,
1920    staging: &Path,
1921    max_decompressed_bytes: u64,
1922) -> Result<(), Response> {
1923    let staging_clone = staging.to_path_buf();
1924    let tarball_clone = tarball_path.to_path_buf();
1925    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
1926        let file = std::fs::File::open(&tarball_clone)?;
1927        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
1928        let limited = SizeLimitReader {
1929            inner: gz,
1930            remaining: max_decompressed_bytes,
1931        };
1932        let mut archive = tar::Archive::new(limited);
1933        archive.set_overwrite(true);
1934        archive.set_preserve_permissions(false);
1935        std::fs::create_dir_all(&staging_clone)?;
1936        archive.unpack(&staging_clone)?;
1937        Ok(())
1938    })
1939    .await;
1940    let _ = tokio::fs::remove_file(tarball_path).await;
1941
1942    match extract_result {
1943        Ok(Ok(())) => Ok(()),
1944        Ok(Err(e)) => {
1945            let _ = tokio::fs::remove_dir_all(staging).await;
1946            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
1947            tracing::warn!(
1948                event = "upload_extract_error",
1949                "tarball extraction failed: {e:#}"
1950            );
1951            let (status, msg) = if is_size_limit {
1952                (
1953                    StatusCode::PAYLOAD_TOO_LARGE,
1954                    "Archive exceeds the decompressed size limit",
1955                )
1956            } else {
1957                (StatusCode::BAD_REQUEST, "Failed to extract archive")
1958            };
1959            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
1960        }
1961        Err(e) => {
1962            let _ = tokio::fs::remove_dir_all(staging).await;
1963            tracing::error!(
1964                event = "upload_extract_panic",
1965                "tarball extraction task panicked: {e}"
1966            );
1967            Err((
1968                StatusCode::INTERNAL_SERVER_ERROR,
1969                Json(serde_json::json!({"error": "Archive extraction failed"})),
1970            )
1971                .into_response())
1972        }
1973    }
1974}
1975
1976/// If `staging` contains exactly one top-level directory, return its path
1977/// (the common case when the archive was created with `webkitRelativePath`).
1978/// Otherwise return `None`.
1979async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
1980    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
1981    let first = entries.next_entry().await.ok()??;
1982    if !first.path().is_dir() {
1983        return None;
1984    }
1985    if entries.next_entry().await.unwrap_or(None).is_some() {
1986        return None;
1987    }
1988    Some(first.path())
1989}
1990
1991/// Request body for `POST /api/upload-directory`.
1992///
1993/// Each entry carries a relative path (identical to the browser's
1994/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
1995/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
1996/// avoids pulling in a `multipart` library that is not in the vendor archive.
1997#[derive(Deserialize)]
1998struct UploadDirRequest {
1999    files: Vec<UploadedFile>,
2000    /// If provided, append this batch to an existing upload session instead of
2001    /// creating a new staging directory. Must be a plain UUID (no path separators).
2002    upload_id: Option<String>,
2003}
2004
2005#[derive(Deserialize)]
2006struct UploadedFile {
2007    /// `webkitRelativePath` value from the browser File object.
2008    path: String,
2009    /// Raw file bytes encoded as standard base64.
2010    content: String,
2011}
2012
2013/// POST /api/upload-directory
2014///
2015/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
2016/// Saves all files to a temp staging directory preserving their relative paths,
2017/// then returns the server-side root directory path so the caller can populate
2018/// the scan-path field and run a normal analysis.
2019///
2020/// Only available in server mode; returns 404 in local mode (use the native
2021/// rfd dialog instead).
2022async fn upload_directory_handler(
2023    State(state): State<AppState>,
2024    Json(body): Json<UploadDirRequest>,
2025) -> Response {
2026    if !state.server_mode {
2027        return StatusCode::NOT_FOUND.into_response();
2028    }
2029    if let Err(resp) = validate_upload_dir_request(&body) {
2030        return resp;
2031    }
2032    // Reuse an existing staging dir when the client sends a continuation batch,
2033    // otherwise create a fresh one. Validate the id to prevent path traversal.
2034    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2035    match write_upload_files(&body.files, &staging, &upload_id).await {
2036        Ok((file_count, project_root)) => {
2037            let scan_root = project_root.unwrap_or_else(|| staging.clone());
2038            Json(serde_json::json!({
2039                "tmp_path": scan_root.to_string_lossy(),
2040                "file_count": file_count,
2041                "upload_id": upload_id.clone()
2042            }))
2043            .into_response()
2044        }
2045        Err(resp) => resp,
2046    }
2047}
2048
2049/// Request body for `POST /api/upload-file`.
2050#[derive(Deserialize)]
2051struct UploadFileRequest {
2052    /// Original filename (used only to preserve the extension).
2053    filename: String,
2054    /// File bytes encoded as standard base64.
2055    content: String,
2056}
2057
2058/// POST /api/upload-file
2059///
2060/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2061/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2062/// Only available in server mode.
2063async fn upload_file_handler(
2064    State(state): State<AppState>,
2065    Json(body): Json<UploadFileRequest>,
2066) -> Response {
2067    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2068
2069    if !state.server_mode {
2070        return StatusCode::NOT_FOUND.into_response();
2071    }
2072
2073    let Ok(data) = base64::Engine::decode(
2074        &base64::engine::general_purpose::STANDARD,
2075        body.content.as_bytes(),
2076    ) else {
2077        return (
2078            StatusCode::BAD_REQUEST,
2079            Json(serde_json::json!({"error": "Invalid base64 content"})),
2080        )
2081            .into_response();
2082    };
2083
2084    if data.len() > MAX_FILE_BYTES {
2085        return (
2086            StatusCode::PAYLOAD_TOO_LARGE,
2087            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2088        )
2089            .into_response();
2090    }
2091
2092    // Sanitise: strip any directory component from the filename.
2093    let filename = std::path::Path::new(&body.filename)
2094        .file_name()
2095        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2096
2097    let upload_id = uuid::Uuid::new_v4();
2098    let staging = std::env::temp_dir()
2099        .join("oxide-sloc-uploads")
2100        .join(upload_id.to_string());
2101
2102    if tokio::fs::create_dir_all(&staging).await.is_err() {
2103        return (
2104            StatusCode::INTERNAL_SERVER_ERROR,
2105            Json(serde_json::json!({"error": "Failed to create staging directory"})),
2106        )
2107            .into_response();
2108    }
2109
2110    let dest = staging.join(&filename);
2111    if tokio::fs::write(&dest, &data).await.is_err() {
2112        let _ = tokio::fs::remove_dir_all(&staging).await;
2113        return (
2114            StatusCode::INTERNAL_SERVER_ERROR,
2115            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2116        )
2117            .into_response();
2118    }
2119
2120    Json(serde_json::json!({
2121        "tmp_path": dest.to_string_lossy(),
2122        "upload_id": upload_id.to_string()
2123    }))
2124    .into_response()
2125}
2126
2127/// POST /api/upload-tarball
2128///
2129/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
2130/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
2131/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
2132/// project root. The two size fields power the "Original / Compressed project size" display in the
2133/// web UI.
2134///
2135/// `DefaultBodyLimit::disable()` is applied per-route so there is no hard size cap at the HTTP
2136/// layer; the only limit is the disk space on the server. The browser-side JS creates the archive
2137/// one file at a time using the native `CompressionStream('gzip')` API so browser RAM usage stays
2138/// bounded regardless of project size.
2139/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
2140/// decompressed. Wraps any `std::io::Read` source.
2141struct SizeLimitReader<R> {
2142    inner: R,
2143    remaining: u64,
2144}
2145impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2146    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2147        if self.remaining == 0 {
2148            return Err(std::io::Error::other("decompressed size limit exceeded"));
2149        }
2150        let n = self.inner.read(buf)?;
2151        self.remaining = self.remaining.saturating_sub(n as u64);
2152        Ok(n)
2153    }
2154}
2155
2156async fn upload_tarball_handler(
2157    State(state): State<AppState>,
2158    request: axum::extract::Request,
2159) -> Response {
2160    if !state.server_mode {
2161        return StatusCode::NOT_FOUND.into_response();
2162    }
2163
2164    let upload_id = uuid::Uuid::new_v4().to_string();
2165    let upload_base = upload_base_dir();
2166    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2167    let staging = upload_staging_path(&upload_id);
2168    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2169
2170    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2171        tracing::error!(
2172            event = "upload_io_error",
2173            "failed to create upload base dir: {e}"
2174        );
2175        return (
2176            StatusCode::INTERNAL_SERVER_ERROR,
2177            Json(serde_json::json!({"error": "Upload initialization failed"})),
2178        )
2179            .into_response();
2180    }
2181
2182    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
2183    let compressed_bytes =
2184        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2185            Ok(n) => n,
2186            Err(resp) => return resp,
2187        };
2188
2189    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
2190    if let Err(resp) =
2191        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2192    {
2193        return resp;
2194    }
2195
2196    // ── 3. Find the project root inside the staging dir ───────────────────────
2197    // If the tar contained a single top-level directory (the common case when the
2198    // browser uses `webkitRelativePath`), return that as the scan root so the path
2199    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
2200    let scan_root = find_single_top_dir(&staging)
2201        .await
2202        .unwrap_or_else(|| staging.clone());
2203
2204    // Compute original (uncompressed) size of the extracted tree.
2205    let original_bytes = tokio::task::spawn_blocking({
2206        let p = scan_root.clone();
2207        move || dir_size_bytes(&p)
2208    })
2209    .await
2210    .unwrap_or(0);
2211
2212    Json(serde_json::json!({
2213        "tmp_path": scan_root.to_string_lossy(),
2214        "upload_id": upload_id,
2215        "compressed_bytes": compressed_bytes,
2216        "original_bytes": original_bytes,
2217    }))
2218    .into_response()
2219}
2220
2221#[derive(Deserialize)]
2222struct LocateReportForm {
2223    file_path: String,
2224}
2225
2226/// Render a view-reports error page and return it as a `Response`.
2227fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2228    let html = ErrorTemplate {
2229        message: message.into(),
2230        last_report_url: Some("/view-reports".to_string()),
2231        last_report_label: Some("View Reports".to_string()),
2232        run_id: None,
2233        error_code: None,
2234        csp_nonce: csp_nonce.to_owned(),
2235        version: env!("CARGO_PKG_VERSION"),
2236    }
2237    .render()
2238    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2239    Html(html).into_response()
2240}
2241
2242/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
2243fn registry_entry_from_run(
2244    run: &AnalysisRun,
2245    json_path: PathBuf,
2246    html_path: PathBuf,
2247) -> RegistryEntry {
2248    let project_label = run.input_roots.first().map_or_else(
2249        || "Unknown Project".to_string(),
2250        |r| sanitize_project_label(r),
2251    );
2252    RegistryEntry {
2253        run_id: run.tool.run_id.clone(),
2254        timestamp_utc: run.tool.timestamp_utc,
2255        project_label,
2256        input_roots: run.input_roots.clone(),
2257        json_path: Some(json_path),
2258        html_path: Some(html_path),
2259        pdf_path: None,
2260        summary: ScanSummarySnapshot {
2261            files_analyzed: run.summary_totals.files_analyzed,
2262            files_skipped: run.summary_totals.files_skipped,
2263            total_physical_lines: run.summary_totals.total_physical_lines,
2264            code_lines: run.summary_totals.code_lines,
2265            comment_lines: run.summary_totals.comment_lines,
2266            blank_lines: run.summary_totals.blank_lines,
2267            functions: run.summary_totals.functions,
2268            classes: run.summary_totals.classes,
2269            variables: run.summary_totals.variables,
2270            imports: run.summary_totals.imports,
2271            test_count: run.summary_totals.test_count,
2272            coverage_lines_found: run.summary_totals.coverage_lines_found,
2273            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2274            coverage_functions_found: run.summary_totals.coverage_functions_found,
2275            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2276            coverage_branches_found: run.summary_totals.coverage_branches_found,
2277            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2278        },
2279        csv_path: None,
2280        xlsx_path: None,
2281        git_branch: None,
2282        git_commit: None,
2283        git_author: None,
2284        git_tags: None,
2285        git_nearest_tag: None,
2286        git_commit_date: None,
2287    }
2288}
2289
2290/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
2291/// immediately without requiring a server restart.
2292pub(crate) async fn register_artifacts_in_registry(
2293    state: &AppState,
2294    label: &str,
2295    run: &AnalysisRun,
2296    artifacts: &RunArtifacts,
2297) {
2298    let Some(json_path) = artifacts.json_path.clone() else {
2299        return;
2300    };
2301    let Some(html_path) = artifacts.html_path.clone() else {
2302        return;
2303    };
2304    let mut entry = registry_entry_from_run(run, json_path, html_path);
2305    entry.project_label = label.to_owned();
2306    let mut reg = state.registry.lock().await;
2307    reg.add_entry(entry);
2308    let _ = reg.save(&state.registry_path);
2309}
2310
2311/// Validate the locate-report form: check extension, resolve the canonical path, enforce
2312/// server-mode root restriction, and extract the parent directory.
2313///
2314/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
2315#[allow(clippy::result_large_err)]
2316fn validate_locate_request(
2317    state: &AppState,
2318    file_path: &str,
2319    csp_nonce: &str,
2320) -> Result<(PathBuf, PathBuf), Response> {
2321    let file_ext = Path::new(file_path)
2322        .extension()
2323        .and_then(|e| e.to_str())
2324        .unwrap_or("")
2325        .to_ascii_lowercase();
2326    if file_ext != "html" {
2327        return Err(locate_report_error(
2328            "Only .html report files can be located via this form.",
2329            csp_nonce,
2330        ));
2331    }
2332    let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
2333        Ok(p) => strip_unc_prefix(p),
2334        Err(_) => {
2335            return Err(locate_report_error(
2336                "Report file not found or path is invalid.",
2337                csp_nonce,
2338            ));
2339        }
2340    };
2341    if state.server_mode {
2342        let output_root = resolve_output_root(None);
2343        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2344        if !html_path.starts_with(&canonical_root) {
2345            return Err(locate_report_error(
2346                "Report file must be within the configured output directory.",
2347                csp_nonce,
2348            ));
2349        }
2350    }
2351    let parent = match html_path.parent() {
2352        Some(p) => p.to_path_buf(),
2353        None => {
2354            return Err(locate_report_error(
2355                "Report file has no parent directory.",
2356                csp_nonce,
2357            ));
2358        }
2359    };
2360    Ok((html_path, parent))
2361}
2362
2363/// Return a non-sensitive path hint for error messages (empty in server mode).
2364fn locate_path_hint(server_mode: bool, path: &Path) -> String {
2365    if server_mode {
2366        String::new()
2367    } else {
2368        format!("\n\nFile: {}", path.display())
2369    }
2370}
2371
2372async fn locate_report_handler(
2373    State(state): State<AppState>,
2374    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2375    Form(form): Form<LocateReportForm>,
2376) -> impl IntoResponse {
2377    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2378        Ok(v) => v,
2379        Err(resp) => return resp,
2380    };
2381
2382    let json_candidate = parent.join("result.json");
2383    let mut reg = state.registry.lock().await;
2384    // Find an existing entry whose output directory matches the selected file's parent.
2385    let entry_idx = reg.entries.iter().position(|e| {
2386        let json_match = e
2387            .json_path
2388            .as_ref()
2389            .and_then(|p| p.parent())
2390            .is_some_and(|p| p == parent);
2391        let html_match = e
2392            .html_path
2393            .as_ref()
2394            .and_then(|p| p.parent())
2395            .is_some_and(|p| p == parent);
2396        json_match || html_match
2397    });
2398    if let Some(idx) = entry_idx {
2399        reg.entries[idx].html_path = Some(html_path);
2400        let _ = reg.save(&state.registry_path);
2401        return axum::response::Redirect::to("/view-reports?linked=1").into_response();
2402    }
2403    // No match — attempt to build an entry from an adjacent result.json.
2404    if json_candidate.exists() {
2405        match read_json(&json_candidate) {
2406            Ok(run) => {
2407                let entry = registry_entry_from_run(&run, json_candidate, html_path);
2408                reg.add_entry(entry);
2409                let _ = reg.save(&state.registry_path);
2410                return axum::response::Redirect::to("/view-reports?linked=1").into_response();
2411            }
2412            Err(e) => {
2413                let file_hint = locate_path_hint(state.server_mode, &json_candidate);
2414                let err_detail = if state.server_mode {
2415                    String::new()
2416                } else {
2417                    format!("\n\nError: {e}")
2418                };
2419                return locate_report_error(
2420                    format!(
2421                        "Could not link this report.\n\nA 'result.json' was found but could not \
2422                         be parsed — it may have been saved by an older version of OxideSLOC. \
2423                         Re-running the analysis will create a fresh, compatible \
2424                         record.{file_hint}{err_detail}"
2425                    ),
2426                    &csp_nonce,
2427                );
2428            }
2429        }
2430    }
2431    drop(reg);
2432    let file_hint = locate_path_hint(state.server_mode, &html_path);
2433    locate_report_error(
2434        format!(
2435            "Could not link this report.\n\nNo matching scan record was found, and no \
2436             'result.json' was found in the same folder.{file_hint}"
2437        ),
2438        &csp_nonce,
2439    )
2440}
2441
2442/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
2443fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2444    fs::read_dir(dir)
2445        .ok()?
2446        .flatten()
2447        .map(|e| e.path())
2448        .find(|p| {
2449            p.is_file()
2450                && p.file_stem()
2451                    .and_then(|n| n.to_str())
2452                    .is_some_and(|n| n.starts_with("result"))
2453                && p.extension()
2454                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2455        })
2456}
2457
2458#[derive(Deserialize)]
2459struct LocateReportsDirForm {
2460    folder_path: String,
2461}
2462
2463#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
2464async fn locate_reports_dir_handler(
2465    State(state): State<AppState>,
2466    Form(form): Form<LocateReportsDirForm>,
2467) -> impl IntoResponse {
2468    if state.server_mode {
2469        return StatusCode::NOT_FOUND.into_response();
2470    }
2471    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
2472        Ok(p) => strip_unc_prefix(p),
2473        Err(_) => {
2474            return axum::response::Redirect::to(
2475                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
2476            )
2477            .into_response();
2478        }
2479    };
2480    if !folder.is_dir() {
2481        return axum::response::Redirect::to(
2482            "/view-reports?error=Selected+path+is+not+a+directory.",
2483        )
2484        .into_response();
2485    }
2486
2487    let candidates = collect_result_json_candidates(&folder);
2488
2489    if candidates.is_empty() {
2490        return axum::response::Redirect::to(
2491            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
2492        )
2493        .into_response();
2494    }
2495
2496    let mut linked_count: usize = 0;
2497    let mut reg = state.registry.lock().await;
2498    for json_path in candidates {
2499        let Some(parent) = json_path.parent().map(PathBuf::from) else {
2500            continue;
2501        };
2502        if is_dir_already_registered(&reg, &parent) {
2503            continue;
2504        }
2505        let Some(entry) = build_registry_entry_from_json(json_path) else {
2506            continue;
2507        };
2508        reg.add_entry(entry);
2509        linked_count += 1;
2510    }
2511    let _ = reg.save(&state.registry_path);
2512    drop(reg);
2513
2514    if linked_count == 0 {
2515        return axum::response::Redirect::to(
2516            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2517        )
2518        .into_response();
2519    }
2520    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2521}
2522
2523#[derive(Deserialize)]
2524struct RelocateScanForm {
2525    run_id: String,
2526    folder_path: String,
2527    redirect_url: String,
2528}
2529
2530async fn relocate_scan_handler(
2531    State(state): State<AppState>,
2532    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2533    Form(form): Form<RelocateScanForm>,
2534) -> impl IntoResponse {
2535    if state.server_mode {
2536        return StatusCode::NOT_FOUND.into_response();
2537    }
2538
2539    let run_id = form.run_id.trim().to_string();
2540    let redirect_url = form.redirect_url.trim().to_string();
2541
2542    let run_exists = {
2543        let reg = state.registry.lock().await;
2544        reg.find_by_run_id(&run_id).is_some()
2545    };
2546    if !run_exists {
2547        let html = ErrorTemplate {
2548            message: format!("Run ID '{run_id}' not found in registry."),
2549            last_report_url: Some("/compare-scans".to_string()),
2550            last_report_label: Some("Compare Scans".to_string()),
2551            run_id: Some(run_id.clone()),
2552            error_code: Some(404),
2553            csp_nonce: csp_nonce.clone(),
2554            version: env!("CARGO_PKG_VERSION"),
2555        }
2556        .render()
2557        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2558        return Html(html).into_response();
2559    }
2560
2561    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2562        Ok(p) => strip_unc_prefix(p),
2563        Err(_) => {
2564            return missing_scan_relocate_response(
2565                "Folder not found or path is invalid.",
2566                &run_id,
2567                form.folder_path.trim(),
2568                &redirect_url,
2569                false,
2570                &csp_nonce,
2571            );
2572        }
2573    };
2574    if !folder.is_dir() {
2575        return missing_scan_relocate_response(
2576            "Selected path is not a directory.",
2577            &run_id,
2578            &folder.display().to_string(),
2579            &redirect_url,
2580            false,
2581            &csp_nonce,
2582        );
2583    }
2584
2585    let json_candidates = find_result_files_by_ext(&folder, "json");
2586    if json_candidates.is_empty() {
2587        return missing_scan_relocate_response(
2588            &format!(
2589                "No result JSON files found in the selected folder.\nSearched: {}",
2590                folder.display()
2591            ),
2592            &run_id,
2593            &folder.display().to_string(),
2594            &redirect_url,
2595            false,
2596            &csp_nonce,
2597        );
2598    }
2599
2600    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
2601        return missing_scan_relocate_response(
2602            &format!(
2603                "No matching scan found in the selected folder.\n\
2604                 The JSON files present do not contain run ID: {run_id}\n\
2605                 Searched: {}",
2606                folder.display()
2607            ),
2608            &run_id,
2609            &folder.display().to_string(),
2610            &redirect_url,
2611            false,
2612            &csp_nonce,
2613        );
2614    };
2615
2616    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
2617    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
2618    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
2619
2620    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
2621        redirect_url
2622    } else {
2623        "/compare-scans".to_string()
2624    };
2625    axum::response::Redirect::to(&safe_redirect).into_response()
2626}
2627
2628fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
2629    fs::read_dir(folder)
2630        .ok()
2631        .into_iter()
2632        .flatten()
2633        .flatten()
2634        .map(|e| e.path())
2635        .filter(|p| {
2636            p.is_file()
2637                && p.file_stem()
2638                    .and_then(|n| n.to_str())
2639                    .is_some_and(|n| n.starts_with("result"))
2640                && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
2641        })
2642        .collect()
2643}
2644
2645fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
2646    candidates
2647        .iter()
2648        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
2649        .cloned()
2650}
2651
2652async fn update_run_file_paths(
2653    state: &AppState,
2654    run_id: &str,
2655    json_path: PathBuf,
2656    html_path: Option<PathBuf>,
2657    pdf_path: Option<PathBuf>,
2658) {
2659    let mut reg = state.registry.lock().await;
2660    if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2661        entry.json_path = Some(json_path);
2662        if let Some(hp) = html_path {
2663            entry.html_path = Some(hp);
2664        }
2665        if let Some(pp) = pdf_path {
2666            entry.pdf_path = Some(pp);
2667        }
2668    }
2669    let _ = reg.save(&state.registry_path);
2670}
2671
2672fn missing_scan_relocate_response(
2673    message: &str,
2674    run_id: &str,
2675    folder_hint: &str,
2676    redirect_url: &str,
2677    server_mode: bool,
2678    csp_nonce: &str,
2679) -> axum::response::Response {
2680    let html = RelocateScanTemplate {
2681        message: message.to_string(),
2682        run_id: run_id.to_string(),
2683        folder_hint: folder_hint.to_string(),
2684        redirect_url: redirect_url.to_string(),
2685        server_mode,
2686        csp_nonce: csp_nonce.to_owned(),
2687        version: env!("CARGO_PKG_VERSION"),
2688    }
2689    .render()
2690    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2691    (StatusCode::NOT_FOUND, Html(html)).into_response()
2692}
2693
2694// ── Watched-directory helpers ─────────────────────────────────────────────────
2695
2696/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
2697fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
2698    let mut candidates = Vec::new();
2699    if let Some(j) = find_result_json_in_dir(folder) {
2700        candidates.push(j);
2701    }
2702    if let Ok(dir_entries) = fs::read_dir(folder) {
2703        for entry in dir_entries.flatten() {
2704            let sub = entry.path();
2705            if sub.is_dir() {
2706                if let Some(j) = find_result_json_in_dir(&sub) {
2707                    candidates.push(j);
2708                }
2709            }
2710        }
2711    }
2712    candidates
2713}
2714
2715fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
2716    reg.entries.iter().any(|e| {
2717        let dir_match = e
2718            .json_path
2719            .as_ref()
2720            .and_then(|p| p.parent())
2721            .is_some_and(|p| p == parent)
2722            || e.html_path
2723                .as_ref()
2724                .and_then(|p| p.parent())
2725                .is_some_and(|p| p == parent);
2726        dir_match
2727            && (e.json_path.as_ref().is_some_and(|p| p.exists())
2728                || e.html_path.as_ref().is_some_and(|p| p.exists()))
2729    })
2730}
2731
2732fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
2733    let parent = json_path.parent()?.to_path_buf();
2734    let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2735        rd.flatten()
2736            .map(|e| e.path())
2737            .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2738    });
2739    let run = read_json(&json_path).ok()?;
2740    let project_label = run.input_roots.first().map_or_else(
2741        || "Unknown Project".to_string(),
2742        |r| sanitize_project_label(r),
2743    );
2744    Some(RegistryEntry {
2745        run_id: run.tool.run_id.clone(),
2746        timestamp_utc: run.tool.timestamp_utc,
2747        project_label,
2748        input_roots: run.input_roots.clone(),
2749        json_path: Some(json_path),
2750        html_path,
2751        pdf_path: None,
2752        csv_path: None,
2753        xlsx_path: None,
2754        summary: ScanSummarySnapshot {
2755            files_analyzed: run.summary_totals.files_analyzed,
2756            files_skipped: run.summary_totals.files_skipped,
2757            total_physical_lines: run.summary_totals.total_physical_lines,
2758            code_lines: run.summary_totals.code_lines,
2759            comment_lines: run.summary_totals.comment_lines,
2760            blank_lines: run.summary_totals.blank_lines,
2761            functions: run.summary_totals.functions,
2762            classes: run.summary_totals.classes,
2763            variables: run.summary_totals.variables,
2764            imports: run.summary_totals.imports,
2765            test_count: run.summary_totals.test_count,
2766            coverage_lines_found: run.summary_totals.coverage_lines_found,
2767            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2768            coverage_functions_found: run.summary_totals.coverage_functions_found,
2769            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2770            coverage_branches_found: run.summary_totals.coverage_branches_found,
2771            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2772        },
2773        git_branch: run.git_branch.clone(),
2774        git_commit: run.git_commit_short.clone(),
2775        git_author: run.git_commit_author.clone(),
2776        git_tags: run.git_tags.clone(),
2777        git_nearest_tag: run.git_nearest_tag.clone(),
2778        git_commit_date: run.git_commit_date,
2779    })
2780}
2781
2782/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
2783/// Returns the number of newly linked entries.
2784fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
2785    let mut linked = 0usize;
2786    for json_path in collect_result_json_candidates(folder) {
2787        let Some(parent) = json_path.parent().map(PathBuf::from) else {
2788            continue;
2789        };
2790        if is_dir_already_registered(reg, &parent) {
2791            continue;
2792        }
2793        let Some(entry) = build_registry_entry_from_json(json_path) else {
2794            continue;
2795        };
2796        reg.add_entry(entry);
2797        linked += 1;
2798    }
2799    linked
2800}
2801
2802/// Scan all watched directories (plus the default output root) into `reg`.
2803async fn auto_scan_watched_dirs(state: &AppState) {
2804    let dirs: Vec<PathBuf> = {
2805        let wd = state.watched_dirs.lock().await;
2806        wd.dirs.clone()
2807    };
2808    if dirs.is_empty() {
2809        return;
2810    }
2811    let mut reg = state.registry.lock().await;
2812    let mut total = 0usize;
2813    for dir in &dirs {
2814        if dir.is_dir() {
2815            total += scan_folder_into_registry(dir, &mut reg);
2816        }
2817    }
2818    if total > 0 {
2819        let _ = reg.save(&state.registry_path);
2820    }
2821}
2822
2823// ── Watched-dir route forms ───────────────────────────────────────────────────
2824
2825#[derive(Deserialize)]
2826struct WatchedDirForm {
2827    folder_path: String,
2828    #[serde(default = "default_redirect")]
2829    redirect_to: String,
2830}
2831
2832fn default_redirect() -> String {
2833    "/view-reports".to_string()
2834}
2835
2836#[derive(Deserialize)]
2837struct WatchedDirRefreshForm {
2838    #[serde(default = "default_redirect")]
2839    redirect_to: String,
2840}
2841
2842// ── Watched-dir helpers ───────────────────────────────────────────────────────
2843
2844/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
2845fn safe_redirect(dest: &str) -> &str {
2846    if dest.starts_with('/') {
2847        dest
2848    } else {
2849        "/"
2850    }
2851}
2852
2853// ── Watched-dir handlers ──────────────────────────────────────────────────────
2854
2855async fn add_watched_dir_handler(
2856    State(state): State<AppState>,
2857    Form(form): Form<WatchedDirForm>,
2858) -> impl IntoResponse {
2859    if state.server_mode {
2860        return StatusCode::NOT_FOUND.into_response();
2861    }
2862    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2863        strip_unc_prefix(p)
2864    } else {
2865        let dest = format!(
2866            "{}?error=Folder+not+found+or+path+is+invalid.",
2867            safe_redirect(&form.redirect_to)
2868        );
2869        return axum::response::Redirect::to(&dest).into_response();
2870    };
2871    if !folder.is_dir() {
2872        let dest = format!(
2873            "{}?error=Selected+path+is+not+a+directory.",
2874            safe_redirect(&form.redirect_to)
2875        );
2876        return axum::response::Redirect::to(&dest).into_response();
2877    }
2878
2879    // Persist the watched directory.
2880    {
2881        let mut wd = state.watched_dirs.lock().await;
2882        wd.add(folder.clone());
2883        let _ = wd.save(&state.watched_dirs_path);
2884    }
2885
2886    // Immediately scan the folder and add any new reports.
2887    let linked = {
2888        let mut reg = state.registry.lock().await;
2889        let n = scan_folder_into_registry(&folder, &mut reg);
2890        if n > 0 {
2891            let _ = reg.save(&state.registry_path);
2892        }
2893        n
2894    };
2895
2896    let dest = if linked > 0 {
2897        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2898    } else {
2899        format!(
2900            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2901            safe_redirect(&form.redirect_to)
2902        )
2903    };
2904    axum::response::Redirect::to(&dest).into_response()
2905}
2906
2907async fn remove_watched_dir_handler(
2908    State(state): State<AppState>,
2909    Form(form): Form<WatchedDirForm>,
2910) -> impl IntoResponse {
2911    if state.server_mode {
2912        return StatusCode::NOT_FOUND.into_response();
2913    }
2914    let folder = PathBuf::from(&form.folder_path);
2915    {
2916        let mut wd = state.watched_dirs.lock().await;
2917        wd.remove(&folder);
2918        let _ = wd.save(&state.watched_dirs_path);
2919    }
2920    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2921}
2922
2923async fn refresh_watched_dirs_handler(
2924    State(state): State<AppState>,
2925    Form(form): Form<WatchedDirRefreshForm>,
2926) -> impl IntoResponse {
2927    if state.server_mode {
2928        return StatusCode::NOT_FOUND.into_response();
2929    }
2930    let dirs: Vec<PathBuf> = {
2931        let wd = state.watched_dirs.lock().await;
2932        wd.dirs.clone()
2933    };
2934    let mut total = 0usize;
2935    {
2936        let mut reg = state.registry.lock().await;
2937        for dir in &dirs {
2938            if dir.is_dir() {
2939                total += scan_folder_into_registry(dir, &mut reg);
2940            }
2941        }
2942        if total > 0 {
2943            let _ = reg.save(&state.registry_path);
2944        }
2945    }
2946    let dest = if total > 0 {
2947        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2948    } else {
2949        safe_redirect(&form.redirect_to).to_owned()
2950    };
2951    axum::response::Redirect::to(&dest).into_response()
2952}
2953
2954#[derive(Debug, Deserialize)]
2955struct OpenPathQuery {
2956    path: Option<String>,
2957}
2958
2959async fn open_path_handler(
2960    State(state): State<AppState>,
2961    Query(query): Query<OpenPathQuery>,
2962) -> impl IntoResponse {
2963    if state.server_mode {
2964        return Json(serde_json::json!({
2965            "server_mode_disabled": true,
2966            "message": "Opening a path in the file manager is only available in local desktop mode."
2967        }))
2968        .into_response();
2969    }
2970    let raw = match query.path.as_deref() {
2971        Some(p) if !p.is_empty() => p,
2972        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2973    };
2974
2975    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
2976    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
2977    // so the file explorer still opens somewhere useful.
2978    let target = match tokio::fs::canonicalize(raw).await {
2979        Ok(canonical) if canonical.is_file() => match canonical.parent() {
2980            Some(p) => p.to_path_buf(),
2981            None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2982        },
2983        Ok(canonical) if canonical.is_dir() => canonical,
2984        Ok(_) => {
2985            return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2986        }
2987        Err(_) => {
2988            // Path doesn't exist — find nearest existing ancestor directory.
2989            let mut ancestor = std::path::Path::new(raw);
2990            loop {
2991                match ancestor.parent() {
2992                    Some(p) => {
2993                        ancestor = p;
2994                        if ancestor.is_dir() {
2995                            break;
2996                        }
2997                    }
2998                    None => {
2999                        return (StatusCode::BAD_REQUEST, "no existing ancestor found")
3000                            .into_response();
3001                    }
3002                }
3003            }
3004            ancestor.to_path_buf()
3005        }
3006    };
3007
3008    #[cfg(target_os = "windows")]
3009    {
3010        // Open the folder in Explorer, then use SetForegroundWindow + ShowWindow(SW_MAXIMIZE=3)
3011        // to ensure the window surfaces on top of all other windows.  The path is passed via
3012        // an environment variable to avoid any command-injection or escaping issues.
3013        let ps_cmd = "Add-Type -TypeDefinition \
3014            'using System;using System.Runtime.InteropServices;\
3015            public class WF{\
3016              [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
3017              [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
3018            }'; \
3019            $p=$env:SLOC_OPEN_PATH; \
3020            $sh=New-Object -ComObject Shell.Application; \
3021            $sh.Open($p); \
3022            Start-Sleep -Milliseconds 600; \
3023            foreach($w in $sh.Windows()){ \
3024              try{ \
3025                if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
3026                   [System.IO.Path]::GetFullPath($p)){ \
3027                  [WF]::ShowWindow($w.HWND,3); \
3028                  [WF]::SetForegroundWindow($w.HWND); \
3029                  break \
3030                } \
3031              }catch{} \
3032            }";
3033        let _ = std::process::Command::new("powershell")
3034            .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
3035            .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
3036            .stdout(Stdio::null())
3037            .stderr(Stdio::null())
3038            .spawn();
3039    }
3040    #[cfg(target_os = "macos")]
3041    let _ = std::process::Command::new("open")
3042        .arg(&target)
3043        .stdout(Stdio::null())
3044        .stderr(Stdio::null())
3045        .spawn();
3046    #[cfg(target_os = "linux")]
3047    let _ = std::process::Command::new("xdg-open")
3048        .arg(&target)
3049        .stdout(Stdio::null())
3050        .stderr(Stdio::null())
3051        .spawn();
3052
3053    (StatusCode::OK, "ok").into_response()
3054}
3055
3056async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3057    let (content_type, bytes): (&'static str, &'static [u8]) =
3058        match (folder.as_str(), file.as_str()) {
3059            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3060            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3061            ("icons", "c.png") => ("image/png", IMG_ICON_C),
3062            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3063            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3064            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3065            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3066            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3067            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3068            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3069            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3070            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3071            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3072            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3073            ("icons", "r.png") => ("image/png", IMG_ICON_R),
3074            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3075            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3076            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3077            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3078            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3079            _ => return StatusCode::NOT_FOUND.into_response(),
3080        };
3081    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3082}
3083
3084async fn preview_handler(
3085    State(state): State<AppState>,
3086    Query(query): Query<PreviewQuery>,
3087) -> impl IntoResponse {
3088    let raw_path = query
3089        .path
3090        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3091    let resolved = resolve_input_path(&raw_path);
3092
3093    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
3094    // binary whose working directory is not the project root), return a clear message
3095    // instead of an opaque OS error from build_preview_html.
3096    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3097        return Html(
3098            r#"<div class="preview-error">Sample directory not available on this server.
3099            Enter a path to a project directory or upload files using Browse.</div>"#
3100                .to_string(),
3101        );
3102    }
3103
3104    if state.server_mode {
3105        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3106        // Upload temp dirs and built-in sample/fixture paths are always safe to preview.
3107        if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3108            let config = &state.base_config;
3109            if config.discovery.allowed_scan_roots.is_empty() {
3110                return Html(
3111                    r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3112                );
3113            }
3114            let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3115                fs::canonicalize(root)
3116                    .ok()
3117                    .is_some_and(|r| canonical.starts_with(&r))
3118            });
3119            if !allowed {
3120                return Html(
3121                    r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3122                );
3123            }
3124        }
3125    }
3126
3127    let include_patterns = split_patterns(query.include_globs.as_deref());
3128    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3129
3130    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3131        Ok(html) => Html(html),
3132        Err(err) => Html(format!(
3133            r#"<div class="preview-error">Preview failed: {}</div>"#,
3134            escape_html(&err.to_string())
3135        )),
3136    }
3137}
3138
3139#[derive(Debug, Deserialize, Default)]
3140struct SuggestCoverageQuery {
3141    path: Option<String>,
3142}
3143
3144#[derive(Serialize)]
3145struct SuggestCoverageResponse {
3146    found: Option<String>,
3147    tool: Option<&'static str>,
3148    hint: Option<&'static str>,
3149}
3150
3151async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3152    const CANDIDATES: &[&str] = &[
3153        // LCOV — cargo-llvm-cov, gcov, lcov
3154        "coverage/lcov.info",
3155        "lcov.info",
3156        "target/llvm-cov/lcov.info",
3157        "target/coverage/lcov.info",
3158        "target/debug/coverage/lcov.info",
3159        "coverage/coverage.lcov",
3160        "build/coverage/lcov.info",
3161        "reports/lcov.info",
3162        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
3163        "coverage.xml",
3164        "coverage/coverage.xml",
3165        "target/site/cobertura/coverage.xml",
3166        "build/reports/coverage/coverage.xml",
3167        // JaCoCo XML — Gradle, Maven JaCoCo plugin
3168        "target/site/jacoco/jacoco.xml",
3169        "build/reports/jacoco/test/jacocoTestReport.xml",
3170        "build/reports/jacoco/jacocoTestReport.xml",
3171        "build/jacoco/jacoco.xml",
3172    ];
3173    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3174    let found = CANDIDATES
3175        .iter()
3176        .map(|rel| root.join(rel))
3177        .find(|p| p.is_file())
3178        .map(|p| display_path(&p));
3179
3180    let (tool, hint) = detect_coverage_tool(&root);
3181    Json(SuggestCoverageResponse { found, tool, hint })
3182}
3183
3184/// Inspect the project root for known build/package files and return the most likely coverage
3185/// tool name and the shell command needed to generate a coverage file.
3186fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3187    if root.join("Cargo.toml").is_file() {
3188        return (
3189            Some("cargo-llvm-cov"),
3190            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3191        );
3192    }
3193    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3194        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3195    }
3196    if root.join("pom.xml").is_file() {
3197        return (Some("jacoco"), Some("mvn test jacoco:report"));
3198    }
3199    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3200        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3201    }
3202    (None, None)
3203}
3204
3205/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
3206#[allow(clippy::result_large_err)]
3207fn validate_server_scan_path(
3208    config: &sloc_config::AppConfig,
3209    resolved_path: &Path,
3210    csp_nonce: &str,
3211) -> Result<(), Response> {
3212    if config.discovery.allowed_scan_roots.is_empty() {
3213        let template = ErrorTemplate {
3214            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3215                      Set allowed_scan_roots in the server config to permit scanning."
3216                .to_string(),
3217            last_report_url: None,
3218            last_report_label: None,
3219            run_id: None,
3220            error_code: Some(403),
3221            csp_nonce: csp_nonce.to_owned(),
3222            version: env!("CARGO_PKG_VERSION"),
3223        };
3224        return Err((
3225            StatusCode::FORBIDDEN,
3226            Html(
3227                template
3228                    .render()
3229                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3230            ),
3231        )
3232            .into_response());
3233    }
3234    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3235    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3236        fs::canonicalize(root)
3237            .ok()
3238            .is_some_and(|r| canonical.starts_with(&r))
3239    });
3240    if !allowed {
3241        tracing::warn!(event = "path_rejected", path = %canonical.display(),
3242            "Scan path not in allowed_scan_roots");
3243        let template = ErrorTemplate {
3244            message: "The requested path is not within an allowed scan directory.".to_string(),
3245            last_report_url: None,
3246            last_report_label: None,
3247            run_id: None,
3248            error_code: Some(403),
3249            csp_nonce: csp_nonce.to_owned(),
3250            version: env!("CARGO_PKG_VERSION"),
3251        };
3252        return Err((
3253            StatusCode::FORBIDDEN,
3254            Html(
3255                template
3256                    .render()
3257                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3258            ),
3259        )
3260            .into_response());
3261    }
3262    Ok(())
3263}
3264
3265/// Exclude the output directory from scanning so artifacts don't pollute counts.
3266fn apply_output_dir_exclusions(
3267    config: &mut sloc_config::AppConfig,
3268    project_path: &str,
3269    raw_output_dir: &str,
3270) {
3271    let project_root = resolve_input_path(project_path);
3272    let raw_out = raw_output_dir.trim();
3273    let resolved_out = if raw_out.is_empty() {
3274        project_root.join("sloc")
3275    } else if Path::new(raw_out).is_absolute() {
3276        PathBuf::from(raw_out)
3277    } else {
3278        workspace_root().join(raw_out)
3279    };
3280    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3281        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3282            let dir = first.to_string();
3283            if !config.discovery.excluded_directories.contains(&dir) {
3284                config.discovery.excluded_directories.push(dir);
3285            }
3286        }
3287    }
3288    if !config
3289        .discovery
3290        .excluded_directories
3291        .iter()
3292        .any(|d| d == "sloc")
3293    {
3294        config
3295            .discovery
3296            .excluded_directories
3297            .push("sloc".to_string());
3298    }
3299}
3300
3301/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
3302const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3303    ScanSummarySnapshot {
3304        files_analyzed: run.summary_totals.files_analyzed,
3305        files_skipped: run.summary_totals.files_skipped,
3306        total_physical_lines: run.summary_totals.total_physical_lines,
3307        code_lines: run.summary_totals.code_lines,
3308        comment_lines: run.summary_totals.comment_lines,
3309        blank_lines: run.summary_totals.blank_lines,
3310        functions: run.summary_totals.functions,
3311        classes: run.summary_totals.classes,
3312        variables: run.summary_totals.variables,
3313        imports: run.summary_totals.imports,
3314        test_count: run.summary_totals.test_count,
3315        coverage_lines_found: run.summary_totals.coverage_lines_found,
3316        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3317        coverage_functions_found: run.summary_totals.coverage_functions_found,
3318        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3319        coverage_branches_found: run.summary_totals.coverage_branches_found,
3320        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3321    }
3322}
3323
3324/// Build the `RegistryEntry` for the just-completed scan run.
3325pub(crate) fn build_run_registry_entry(
3326    run: &AnalysisRun,
3327    run_id: &str,
3328    project_label: &str,
3329    artifacts: &RunArtifacts,
3330) -> RegistryEntry {
3331    RegistryEntry {
3332        run_id: run_id.to_owned(),
3333        timestamp_utc: run.tool.timestamp_utc,
3334        project_label: project_label.to_owned(),
3335        input_roots: run.input_roots.clone(),
3336        json_path: artifacts.json_path.clone(),
3337        html_path: artifacts.html_path.clone(),
3338        pdf_path: artifacts.pdf_path.clone(),
3339        csv_path: artifacts.csv_path.clone(),
3340        xlsx_path: artifacts.xlsx_path.clone(),
3341        summary: summary_snapshot_from_run(run),
3342        git_branch: run.git_branch.clone(),
3343        git_commit: run.git_commit_short.clone(),
3344        git_author: run.git_commit_author.clone(),
3345        git_tags: run.git_tags.clone(),
3346        git_nearest_tag: run.git_nearest_tag.clone(),
3347        git_commit_date: run.git_commit_date.clone(),
3348    }
3349}
3350
3351/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
3352fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3353    if let Some(policy) = form.mixed_line_policy {
3354        config.analysis.mixed_line_policy = policy;
3355    }
3356    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3357    config.analysis.generated_file_detection =
3358        form.generated_file_detection.as_deref() != Some("disabled");
3359    config.analysis.minified_file_detection =
3360        form.minified_file_detection.as_deref() != Some("disabled");
3361    config.analysis.vendor_directory_detection =
3362        form.vendor_directory_detection.as_deref() != Some("disabled");
3363    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3364    if let Some(binary_behavior) = form.binary_file_behavior {
3365        config.analysis.binary_file_behavior = binary_behavior;
3366    }
3367    if let Some(report_title) = form.report_title.as_deref() {
3368        let trimmed = report_title.trim();
3369        if !trimmed.is_empty() {
3370            config.reporting.report_title = trimmed.to_string();
3371        }
3372    }
3373    if let Some(hf) = form.report_header_footer.as_deref() {
3374        let trimmed = hf.trim();
3375        config.reporting.report_header_footer = if trimmed.is_empty() {
3376            None
3377        } else {
3378            Some(trimmed.to_string())
3379        };
3380    }
3381    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3382    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3383    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3384    if let Some(policy) = form.continuation_line_policy {
3385        config.analysis.continuation_line_policy = policy;
3386    }
3387    if let Some(policy) = form.blank_in_block_comment_policy {
3388        config.analysis.blank_in_block_comment_policy = policy;
3389    }
3390    config.analysis.count_compiler_directives =
3391        form.count_compiler_directives.as_deref() != Some("disabled");
3392    if let Some(cov) = &form.coverage_file {
3393        let trimmed = cov.trim();
3394        if !trimmed.is_empty() {
3395            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
3396        }
3397    }
3398}
3399
3400/// Fire-and-forget: generate the PDF in a background task if one is pending.
3401/// On failure, clears `pdf_path` in the artifacts map so the results page shows
3402/// an error instead of spinning indefinitely.
3403fn spawn_pdf_background(
3404    pending_pdf: PendingPdf,
3405    run_id: String,
3406    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3407) {
3408    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
3409        tokio::spawn(async move {
3410            let result = tokio::task::spawn_blocking(move || {
3411                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
3412                if cleanup_src {
3413                    let _ = fs::remove_file(&pdf_src);
3414                }
3415                r
3416            })
3417            .await;
3418            let failed = match result {
3419                Ok(Ok(())) => false,
3420                Ok(Err(err)) => {
3421                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
3422                    true
3423                }
3424                Err(err) => {
3425                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
3426                    true
3427                }
3428            };
3429            if failed {
3430                let mut map = artifacts.lock().await;
3431                if let Some(entry) = map.get_mut(&run_id) {
3432                    entry.pdf_path = None;
3433                }
3434            }
3435        });
3436    }
3437}
3438
3439/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
3440/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
3441/// result page can show an error on the next visit instead of spinning indefinitely.
3442fn spawn_native_pdf_background(
3443    json_path: PathBuf,
3444    pdf_dest: PathBuf,
3445    run_id: String,
3446    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3447) {
3448    tokio::spawn(async move {
3449        let result = tokio::task::spawn_blocking(move || {
3450            let run = sloc_core::read_json(&json_path)?;
3451            write_pdf_from_run(&run, &pdf_dest)
3452        })
3453        .await;
3454        let failed = match result {
3455            Ok(Ok(())) => false,
3456            Ok(Err(err)) => {
3457                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
3458                true
3459            }
3460            Err(err) => {
3461                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
3462                true
3463            }
3464        };
3465        if failed {
3466            let mut map = artifacts.lock().await;
3467            if let Some(entry) = map.get_mut(&run_id) {
3468                entry.pdf_path = None;
3469            }
3470        }
3471    });
3472}
3473
3474/// Sum the code lines added in this comparison (new + grown files).
3475fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3476    cmp.file_deltas
3477        .iter()
3478        .map(|f| match f.status {
3479            FileChangeStatus::Added => f.current_code,
3480            FileChangeStatus::Modified => f.code_delta.max(0),
3481            _ => 0,
3482        })
3483        .sum()
3484}
3485
3486/// Sum the code lines removed in this comparison (deleted + shrunk files).
3487fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3488    cmp.file_deltas
3489        .iter()
3490        .map(|f| match f.status {
3491            FileChangeStatus::Removed => f.baseline_code,
3492            FileChangeStatus::Modified => (-f.code_delta).max(0),
3493            _ => 0,
3494        })
3495        .sum()
3496}
3497
3498/// Build one `SubmoduleRow`, optionally generating and persisting a sub-report HTML file.
3499fn build_submodule_row(
3500    s: &sloc_core::SubmoduleSummary,
3501    run: &AnalysisRun,
3502    run_id: &str,
3503    run_dir: &Path,
3504    generate_html: bool,
3505) -> SubmoduleRow {
3506    let safe = sanitize_project_label(&s.name);
3507    let artifact_key = format!("sub_{safe}");
3508    let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
3509        let parent_path = run
3510            .input_roots
3511            .first()
3512            .map_or("", std::string::String::as_str);
3513        let sub_run = build_sub_run(run, s, parent_path);
3514        render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
3515            let path = run_dir.join(format!("{artifact_key}.html"));
3516            if fs::write(&path, sub_html.as_bytes()).is_ok() {
3517                Some(format!("/runs/{artifact_key}/{run_id}"))
3518            } else {
3519                None
3520            }
3521        })
3522    } else {
3523        None
3524    };
3525    SubmoduleRow {
3526        name: s.name.clone(),
3527        relative_path: s.relative_path.clone(),
3528        files_analyzed: s.files_analyzed,
3529        code_lines: s.code_lines,
3530        comment_lines: s.comment_lines,
3531        blank_lines: s.blank_lines,
3532        total_physical_lines: s.total_physical_lines,
3533        html_url,
3534    }
3535}
3536
3537// Immediately returns a wait page and runs the analysis in a background tokio task.
3538// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
3539#[allow(clippy::similar_names)]
3540#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
3541async fn analyze_handler(
3542    State(state): State<AppState>,
3543    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3544    Form(form): Form<AnalyzeForm>,
3545) -> impl IntoResponse {
3546    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
3547        let template = ErrorTemplate {
3548            message: format!(
3549                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
3550             Please wait a moment and try again."
3551            ),
3552            last_report_url: None,
3553            last_report_label: None,
3554            run_id: None,
3555            error_code: Some(503),
3556            csp_nonce: csp_nonce.clone(),
3557            version: env!("CARGO_PKG_VERSION"),
3558        };
3559        return (
3560            StatusCode::SERVICE_UNAVAILABLE,
3561            Html(
3562                template
3563                    .render()
3564                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
3565            ),
3566        )
3567            .into_response();
3568    };
3569
3570    let mut config = state.base_config.clone();
3571
3572    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
3573    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
3574    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
3575
3576    if !is_git_mode {
3577        let resolved_path = resolve_input_path(&form.path);
3578        if state.server_mode
3579            && !is_upload_tmp_path(&resolved_path)
3580            && !is_sample_path(&resolved_path)
3581        {
3582            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
3583                return resp;
3584            }
3585        }
3586        config.discovery.root_paths = vec![resolved_path];
3587    }
3588
3589    apply_form_to_config(&mut config, &form);
3590    apply_output_dir_exclusions(
3591        &mut config,
3592        &form.path,
3593        form.output_dir.as_deref().unwrap_or(""),
3594    );
3595
3596    // Generate a wait_id now (before spawning) so the client can poll for status.
3597    let wait_id = uuid::Uuid::new_v4().to_string();
3598    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
3599
3600    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
3601    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
3602    let task_cancel = Arc::clone(&cancel_token);
3603
3604    // Phase tracker: updated by run_analysis_task at key checkpoints.
3605    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
3606    let task_phase = Arc::clone(&phase);
3607
3608    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3609    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3610    let task_files_done = Arc::clone(&files_done);
3611    let task_files_total = Arc::clone(&files_total);
3612
3613    // Register Running state before building the task struct so the semaphore permit
3614    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
3615    {
3616        let mut runs = state.async_runs.lock().await;
3617        runs.insert(
3618            wait_id.clone(),
3619            AsyncRunState::Running {
3620                started_at: std::time::Instant::now(),
3621                cancel_token,
3622                phase,
3623                files_done,
3624                files_total,
3625            },
3626        );
3627    }
3628
3629    let task = AnalysisTask {
3630        sem_permit,
3631        state: state.clone(),
3632        wait_id: wait_id.clone(),
3633        config,
3634        cancel: task_cancel,
3635        phase: task_phase,
3636        files_done: task_files_done,
3637        files_total: task_files_total,
3638        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
3639        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
3640        generate_html: form.generate_html.is_some(),
3641        generate_pdf: form.generate_pdf.is_some(),
3642        project_path: form.path.clone(),
3643        // In server mode the client-supplied output_dir is ignored — artifacts are
3644        // always written under the server's configured output root so remote users
3645        // cannot direct writes to arbitrary filesystem paths.
3646        output_dir: if state.server_mode {
3647            None
3648        } else {
3649            form.output_dir.clone()
3650        },
3651        clones_dir: state.git_clones_dir.clone(),
3652    };
3653
3654    tokio::spawn(run_analysis_task(task));
3655
3656    let template = ScanWaitTemplate {
3657        version: env!("CARGO_PKG_VERSION"),
3658        wait_id_json,
3659        project_path: form.path.clone(),
3660        csp_nonce,
3661    };
3662    let html = template
3663        .render()
3664        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
3665    let mut response = Html(html).into_response();
3666    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
3667        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
3668            response.headers_mut().insert(name, val);
3669        }
3670    }
3671    response
3672}
3673
3674struct AnalysisTask {
3675    sem_permit: tokio::sync::OwnedSemaphorePermit,
3676    state: AppState,
3677    wait_id: String,
3678    config: AppConfig,
3679    cancel: Arc<std::sync::atomic::AtomicBool>,
3680    phase: Arc<std::sync::Mutex<String>>,
3681    files_done: Arc<std::sync::atomic::AtomicUsize>,
3682    files_total: Arc<std::sync::atomic::AtomicUsize>,
3683    git_repo: Option<String>,
3684    git_ref: Option<String>,
3685    generate_html: bool,
3686    generate_pdf: bool,
3687    project_path: String,
3688    output_dir: Option<String>,
3689    clones_dir: PathBuf,
3690}
3691
3692#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
3693async fn run_analysis_task(task: AnalysisTask) {
3694    let _permit = task.sem_permit;
3695
3696    let cancel_sb = Arc::clone(&task.cancel);
3697    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
3698    let clones_dir_sb = task.clones_dir;
3699    // Save the upload staging path before config is moved into spawn_blocking.
3700    let upload_staging_root = task
3701        .config
3702        .discovery
3703        .root_paths
3704        .first()
3705        .filter(|p| is_upload_tmp_path(p))
3706        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
3707        .map(PathBuf::from);
3708    let config_sb = task.config;
3709    let progress_sb = sloc_core::ProgressCounters {
3710        files_done: Arc::clone(&task.files_done),
3711        files_total: Arc::clone(&task.files_total),
3712    };
3713    if let Ok(mut p) = task.phase.lock() {
3714        *p = "Scanning files".to_string();
3715    }
3716    let analysis_result = tokio::task::spawn_blocking(move || {
3717        run_analysis_blocking(
3718            config_sb,
3719            git_repo_sb,
3720            git_ref_sb,
3721            clones_dir_sb,
3722            cancel_sb,
3723            Some(progress_sb),
3724        )
3725    })
3726    .await
3727    .map_err(|err| anyhow::anyhow!(err.to_string()))
3728    .and_then(|result| result);
3729
3730    if let Ok(mut p) = task.phase.lock() {
3731        *p = "Writing reports".to_string();
3732    }
3733
3734    // If cancelled while running, discard results and mark as cancelled.
3735    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
3736        let mut runs = task.state.async_runs.lock().await;
3737        // Only overwrite if still Running (don't clobber a Complete that snuck in).
3738        if matches!(
3739            runs.get(&task.wait_id),
3740            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
3741        ) {
3742            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
3743        }
3744        drop(runs);
3745        return;
3746    }
3747
3748    let (run, report_html) = match analysis_result {
3749        Ok(v) => v,
3750        Err(err) => {
3751            // Distinguish user-cancelled from real failure.
3752            if err.to_string().contains("analysis cancelled") {
3753                let mut runs = task.state.async_runs.lock().await;
3754                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
3755                drop(runs);
3756                return;
3757            }
3758            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
3759            let mut runs = task.state.async_runs.lock().await;
3760            runs.insert(
3761                task.wait_id.clone(),
3762                AsyncRunState::Failed {
3763                    message: "Analysis failed. Check that the path exists and is readable."
3764                        .to_string(),
3765                },
3766            );
3767            drop(runs);
3768            return;
3769        }
3770    };
3771
3772    let run_id = run.tool.run_id.clone();
3773    tracing::info!(event = "scan_complete", run_id = %run_id,
3774        path = %task.project_path, files = run.summary_totals.files_analyzed,
3775        "Analysis finished");
3776
3777    let prev_entry: Option<RegistryEntry> = {
3778        let reg = task.state.registry.lock().await;
3779        reg.entries_for_roots(&run.input_roots)
3780            .into_iter()
3781            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3782            .cloned()
3783    };
3784
3785    let scan_delta = prev_entry.as_ref().and_then(|prev| {
3786        prev.json_path
3787            .as_ref()
3788            .and_then(|p| read_json(p).ok())
3789            .map(|prev_run| compute_delta(&prev_run, &run))
3790    });
3791    let prev_scan_count: usize = {
3792        let reg = task.state.registry.lock().await;
3793        reg.entries_for_roots(&run.input_roots)
3794            .iter()
3795            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3796            .count()
3797    };
3798
3799    let output_root = resolve_output_root(task.output_dir.as_deref());
3800    let project_label = derive_project_label(
3801        task.git_repo.as_deref(),
3802        task.git_ref.as_deref(),
3803        &task.project_path,
3804    );
3805    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
3806    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
3807
3808    let result_context = RunResultContext {
3809        prev_entry: prev_entry.clone(),
3810        prev_scan_count,
3811        project_path: task.project_path.clone(),
3812    };
3813
3814    let artifact_result = persist_run_artifacts(
3815        &run,
3816        &report_html,
3817        &run_dir,
3818        true,
3819        task.generate_html,
3820        task.generate_pdf,
3821        &run.effective_configuration.reporting.report_title,
3822        &file_stem,
3823        result_context,
3824    );
3825
3826    let (artifacts, pending_pdf) = match artifact_result {
3827        Ok(v) => v,
3828        Err(err) => {
3829            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
3830            let mut runs = task.state.async_runs.lock().await;
3831            runs.insert(
3832                task.wait_id.clone(),
3833                AsyncRunState::Failed {
3834                    message: "Failed to save report artifacts. Check available disk space."
3835                        .to_string(),
3836                },
3837            );
3838            drop(runs);
3839            return;
3840        }
3841    };
3842
3843    {
3844        let mut map = task.state.artifacts.lock().await;
3845        map.insert(run_id.clone(), artifacts.clone());
3846    }
3847
3848    {
3849        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
3850        let mut reg = task.state.registry.lock().await;
3851        reg.add_entry(entry);
3852        let _ = reg.save(&task.state.registry_path);
3853    }
3854
3855    if let Some(ref cfg_path) = artifacts.scan_config_path {
3856        save_scan_config_json(
3857            cfg_path,
3858            &run,
3859            &task.project_path,
3860            task.output_dir.as_deref(),
3861            task.generate_html,
3862            task.generate_pdf,
3863        );
3864    }
3865
3866    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
3867
3868    prom_runs_total().inc();
3869
3870    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
3871    let mut runs = task.state.async_runs.lock().await;
3872    runs.insert(
3873        task.wait_id.clone(),
3874        AsyncRunState::Complete {
3875            run_id: run_id.clone(),
3876        },
3877    );
3878    drop(runs);
3879
3880    // Remove the client-upload staging directory after a successful scan so
3881    // that uploaded project files don't accumulate in the OS temp directory.
3882    if let Some(staging) = upload_staging_root {
3883        let _ = tokio::fs::remove_dir_all(staging).await;
3884    }
3885
3886    let _ = scan_delta;
3887}
3888
3889fn save_scan_config_json(
3890    cfg_path: &std::path::Path,
3891    run: &sloc_core::AnalysisRun,
3892    project_path: &str,
3893    output_dir: Option<&str>,
3894    generate_html: bool,
3895    generate_pdf: bool,
3896) {
3897    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
3898        .ok()
3899        .and_then(|v| v.as_str().map(String::from))
3900        .unwrap_or_else(|| "code_only".to_string());
3901    let behavior_str =
3902        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
3903            .ok()
3904            .and_then(|v| v.as_str().map(String::from))
3905            .unwrap_or_else(|| "skip".to_string());
3906    let scan_cfg = ScanConfig {
3907        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
3908        path: project_path.to_string(),
3909        include_globs: run
3910            .effective_configuration
3911            .discovery
3912            .include_globs
3913            .join("\n"),
3914        exclude_globs: run
3915            .effective_configuration
3916            .discovery
3917            .exclude_globs
3918            .join("\n"),
3919        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
3920        mixed_line_policy: policy_str,
3921        python_docstrings_as_comments: run
3922            .effective_configuration
3923            .analysis
3924            .python_docstrings_as_comments,
3925        generated_file_detection: run
3926            .effective_configuration
3927            .analysis
3928            .generated_file_detection,
3929        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
3930        vendor_directory_detection: run
3931            .effective_configuration
3932            .analysis
3933            .vendor_directory_detection,
3934        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
3935        binary_file_behavior: behavior_str,
3936        output_dir: output_dir.unwrap_or("").to_string(),
3937        report_title: run.effective_configuration.reporting.report_title.clone(),
3938        generate_html,
3939        generate_pdf,
3940    };
3941    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3942        let _ = std::fs::write(cfg_path, json);
3943    }
3944}
3945
3946#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
3947fn run_analysis_blocking(
3948    mut config: AppConfig,
3949    git_repo: Option<String>,
3950    git_ref: Option<String>,
3951    clones_dir: PathBuf,
3952    cancel: Arc<std::sync::atomic::AtomicBool>,
3953    progress: Option<sloc_core::ProgressCounters>,
3954) -> Result<(sloc_core::AnalysisRun, String)> {
3955    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
3956        let dest = git_clone_dest(&repo, &clones_dir);
3957        sloc_git::clone_or_fetch(&repo, &dest)?;
3958        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3959        sloc_git::create_worktree(&dest, &refname, &wt)?;
3960        config.discovery.root_paths = vec![wt.clone()];
3961        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
3962        let _ = sloc_git::destroy_worktree(&dest, &wt);
3963        let mut run = run?;
3964        if run.git_branch.is_none() {
3965            run.git_branch = Some(refname);
3966        }
3967        let html = render_html(&run)?;
3968        return Ok((run, html));
3969    }
3970    let run = analyze(&config, "serve", Some(&cancel), progress.as_ref())?;
3971    let html = render_html(&run)?;
3972    Ok((run, html))
3973}
3974
3975fn derive_project_label(
3976    git_repo: Option<&str>,
3977    git_ref: Option<&str>,
3978    fallback_path: &str,
3979) -> String {
3980    match (
3981        git_repo.filter(|s| !s.is_empty()),
3982        git_ref.filter(|s| !s.is_empty()),
3983    ) {
3984        (Some(repo), Some(refname)) => {
3985            let repo_name = repo
3986                .trim_end_matches('/')
3987                .trim_end_matches(".git")
3988                .rsplit('/')
3989                .next()
3990                .unwrap_or("repo");
3991            sanitize_project_label(&format!("{repo_name}_{refname}"))
3992        }
3993        _ => sanitize_project_label(fallback_path),
3994    }
3995}
3996
3997fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
3998    let commit = commit_short.unwrap_or("").trim();
3999    if commit.is_empty() {
4000        project_label.to_string()
4001    } else {
4002        format!("{project_label}_{commit}")
4003    }
4004}
4005
4006// ── Async scan status + result handlers ──────────────────────────────────────
4007
4008#[derive(Serialize)]
4009#[serde(tag = "state", rename_all = "snake_case")]
4010enum AsyncRunStatusResponse {
4011    Running {
4012        elapsed_secs: u64,
4013        phase: String,
4014        files_done: u64,
4015        files_total: u64,
4016    },
4017    Complete {
4018        run_id: String,
4019    },
4020    Failed {
4021        message: String,
4022    },
4023    Cancelled,
4024}
4025
4026async fn async_run_status_handler(
4027    State(state): State<AppState>,
4028    AxumPath(wait_id): AxumPath<String>,
4029) -> Response {
4030    // wait_id comes from our own UUID generator; reject any structurally malformed value.
4031    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4032        return error::bad_request("invalid wait_id");
4033    }
4034    let run_state = {
4035        let runs = state.async_runs.lock().await;
4036        runs.get(&wait_id).cloned()
4037    };
4038    match run_state {
4039        None => error::not_found("run not found"),
4040        Some(AsyncRunState::Running {
4041            started_at,
4042            phase,
4043            files_done,
4044            files_total,
4045            ..
4046        }) => {
4047            // Treat runs older than 2 h as timed out (analysis should finish well under that).
4048            if started_at.elapsed() > std::time::Duration::from_hours(2) {
4049                let mut runs = state.async_runs.lock().await;
4050                runs.insert(
4051                    wait_id,
4052                    AsyncRunState::Failed {
4053                        message: "Analysis timed out after 2 hours.".to_string(),
4054                    },
4055                );
4056                drop(runs);
4057                return Json(AsyncRunStatusResponse::Failed {
4058                    message: "Analysis timed out after 2 hours.".to_string(),
4059                })
4060                .into_response();
4061            }
4062            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4063            Json(AsyncRunStatusResponse::Running {
4064                elapsed_secs: started_at.elapsed().as_secs(),
4065                phase: phase_str,
4066                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4067                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4068            })
4069            .into_response()
4070        }
4071        Some(AsyncRunState::Complete { run_id }) => {
4072            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4073        }
4074        Some(AsyncRunState::Failed { message }) => {
4075            Json(AsyncRunStatusResponse::Failed { message }).into_response()
4076        }
4077        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4078    }
4079}
4080
4081async fn cancel_run_handler(
4082    State(state): State<AppState>,
4083    AxumPath(wait_id): AxumPath<String>,
4084) -> Response {
4085    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4086        return error::bad_request("invalid wait_id");
4087    }
4088    let mut runs = state.async_runs.lock().await;
4089    let resp = match runs.get(&wait_id) {
4090        Some(AsyncRunState::Running { cancel_token, .. }) => {
4091            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4092            runs.insert(wait_id, AsyncRunState::Cancelled);
4093            StatusCode::OK.into_response()
4094        }
4095        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4096        _ => error::not_found("run not found"),
4097    };
4098    drop(runs);
4099    resp
4100}
4101
4102async fn async_run_result_handler(
4103    State(state): State<AppState>,
4104    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4105    AxumPath(run_id): AxumPath<String>,
4106) -> Response {
4107    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4108        return StatusCode::BAD_REQUEST.into_response();
4109    }
4110
4111    let artifacts = {
4112        let map = state.artifacts.lock().await;
4113        map.get(&run_id).cloned()
4114    };
4115    let artifacts = if let Some(a) = artifacts {
4116        a
4117    } else {
4118        let reg = state.registry.lock().await;
4119        if let Some(entry) = reg.find_by_run_id(&run_id) {
4120            recover_artifacts_from_registry(entry)
4121        } else {
4122            let html = ErrorTemplate {
4123                message: format!(
4124                    "Report not found. Run ID {} is not in the scan history.",
4125                    &run_id[..run_id.len().min(8)]
4126                ),
4127                last_report_url: Some("/view-reports".to_string()),
4128                last_report_label: Some("View Reports".to_string()),
4129                run_id: Some(run_id.clone()),
4130                error_code: Some(404),
4131                csp_nonce: csp_nonce.clone(),
4132                version: env!("CARGO_PKG_VERSION"),
4133            }
4134            .render()
4135            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4136            return (StatusCode::NOT_FOUND, Html(html)).into_response();
4137        }
4138    };
4139
4140    let json_path = if let Some(p) = &artifacts.json_path {
4141        p.clone()
4142    } else {
4143        let html = ErrorTemplate {
4144            message: "JSON result was not saved for this run.".to_string(),
4145            last_report_url: Some("/view-reports".to_string()),
4146            last_report_label: Some("View Reports".to_string()),
4147            run_id: Some(run_id.clone()),
4148            error_code: Some(404),
4149            csp_nonce: csp_nonce.clone(),
4150            version: env!("CARGO_PKG_VERSION"),
4151        }
4152        .render()
4153        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4154        return (StatusCode::NOT_FOUND, Html(html)).into_response();
4155    };
4156
4157    let Ok(run) = read_json(&json_path) else {
4158        let folder_hint = json_path
4159            .parent()
4160            .map(|p| p.display().to_string())
4161            .unwrap_or_default();
4162        let redirect_url = format!("/runs/result/{run_id}");
4163        return missing_scan_relocate_response(
4164            &format!(
4165                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
4166                 deleted. Browse to the folder containing your scan output to reconnect it.",
4167                json_path.display()
4168            ),
4169            &run_id,
4170            &folder_hint,
4171            &redirect_url,
4172            state.server_mode,
4173            &csp_nonce,
4174        );
4175    };
4176
4177    let confluence_configured = {
4178        let store = state.confluence.lock().await;
4179        store.is_configured()
4180    };
4181
4182    render_result_page(
4183        &run,
4184        &artifacts,
4185        &run_id,
4186        &csp_nonce,
4187        confluence_configured,
4188        state.server_mode,
4189    )
4190}
4191
4192#[allow(clippy::too_many_lines)]
4193#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
4194fn render_result_page(
4195    run: &AnalysisRun,
4196    artifacts: &RunArtifacts,
4197    run_id: &str,
4198    csp_nonce: &str,
4199    confluence_configured: bool,
4200    server_mode: bool,
4201) -> Response {
4202    let ctx = &artifacts.result_context;
4203    let prev_entry = &ctx.prev_entry;
4204    let prev_scan_count = ctx.prev_scan_count;
4205    let project_path = &ctx.project_path;
4206
4207    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4208        prev.json_path
4209            .as_ref()
4210            .and_then(|p| read_json(p).ok())
4211            .map(|prev_run| compute_delta(&prev_run, run))
4212    });
4213
4214    let files_analyzed = run.per_file_records.len() as u64;
4215    let files_skipped = run.skipped_file_records.len() as u64;
4216    let physical_lines = run
4217        .totals_by_language
4218        .iter()
4219        .map(|r| r.total_physical_lines)
4220        .sum::<u64>();
4221    let code_lines = run
4222        .totals_by_language
4223        .iter()
4224        .map(|r| r.code_lines)
4225        .sum::<u64>();
4226    let comment_lines = run
4227        .totals_by_language
4228        .iter()
4229        .map(|r| r.comment_lines)
4230        .sum::<u64>();
4231    let blank_lines = run
4232        .totals_by_language
4233        .iter()
4234        .map(|r| r.blank_lines)
4235        .sum::<u64>();
4236    let mixed_lines = run
4237        .totals_by_language
4238        .iter()
4239        .map(|r| r.mixed_lines_separate)
4240        .sum::<u64>();
4241    let functions = run
4242        .totals_by_language
4243        .iter()
4244        .map(|r| r.functions)
4245        .sum::<u64>();
4246    let classes = run
4247        .totals_by_language
4248        .iter()
4249        .map(|r| r.classes)
4250        .sum::<u64>();
4251    let variables = run
4252        .totals_by_language
4253        .iter()
4254        .map(|r| r.variables)
4255        .sum::<u64>();
4256    let imports = run
4257        .totals_by_language
4258        .iter()
4259        .map(|r| r.imports)
4260        .sum::<u64>();
4261
4262    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4263    let prev_fa = prev_sum.map(|s| s.files_analyzed);
4264    let prev_fs = prev_sum.map(|s| s.files_skipped);
4265    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4266    let prev_cl = prev_sum.map(|s| s.code_lines);
4267    let prev_cml = prev_sum.map(|s| s.comment_lines);
4268    let prev_bl = prev_sum.map(|s| s.blank_lines);
4269    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4270    let prev_fa_str = fmt_prev(prev_fa);
4271    let prev_fs_str = fmt_prev(prev_fs);
4272    let prev_pl_str = fmt_prev(prev_pl);
4273    let prev_cl_str = fmt_prev(prev_cl);
4274    let prev_cml_str = fmt_prev(prev_cml);
4275    let prev_bl_str = fmt_prev(prev_bl);
4276    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4277    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4278    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4279    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4280    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4281    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4282    let delta_fa_class = delta_fa_class.to_string();
4283    let delta_fs_class = delta_fs_class.to_string();
4284    let delta_pl_class = delta_pl_class.to_string();
4285    let delta_cl_class = delta_cl_class.to_string();
4286    let delta_cml_class = delta_cml_class.to_string();
4287    let delta_bl_class = delta_bl_class.to_string();
4288
4289    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4290    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4291    let (delta_lines_net_str, delta_lines_net_class) =
4292        match (delta_lines_added, delta_lines_removed) {
4293            (Some(a), Some(r)) => {
4294                let net = a - r;
4295                (fmt_delta(net), delta_class(net).to_string())
4296            }
4297            _ => ("—".to_string(), "na".to_string()),
4298        };
4299
4300    let run_dir = artifacts.output_dir.clone();
4301    let git_branch = run.git_branch.clone();
4302    let git_commit = run.git_commit_short.clone();
4303    let git_commit_long = run.git_commit_long.clone();
4304    let git_author = run.git_commit_author.clone();
4305    let git_commit_url = run
4306        .git_remote_url
4307        .as_deref()
4308        .zip(run.git_commit_long.as_deref())
4309        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4310    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
4311        format!(
4312            "{} / {}",
4313            run.environment.initiator_username, run.environment.initiator_hostname
4314        )
4315    });
4316    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
4317    let os_display = format!(
4318        "{} / {}",
4319        run.environment.operating_system, run.environment.architecture
4320    );
4321    let test_count = run.summary_totals.test_count;
4322
4323    let template = ResultTemplate {
4324        version: env!("CARGO_PKG_VERSION"),
4325        report_title: run.effective_configuration.reporting.report_title.clone(),
4326        project_path: project_path.clone(),
4327        output_dir: display_path(&artifacts.output_dir),
4328        run_id: run_id.to_owned(),
4329        run_id_short: run_id
4330            .split('-')
4331            .next_back()
4332            .unwrap_or(run_id)
4333            .chars()
4334            .take(7)
4335            .collect(),
4336        files_analyzed,
4337        files_skipped,
4338        physical_lines,
4339        code_lines,
4340        comment_lines,
4341        blank_lines,
4342        mixed_lines,
4343        functions,
4344        classes,
4345        variables,
4346        imports,
4347        html_url: artifacts
4348            .html_path
4349            .as_ref()
4350            .map(|_| format!("/runs/html/{run_id}")),
4351        pdf_url: artifacts
4352            .pdf_path
4353            .as_ref()
4354            .map(|_| format!("/runs/pdf/{run_id}")),
4355        json_url: artifacts
4356            .json_path
4357            .as_ref()
4358            .map(|_| format!("/runs/json/{run_id}")),
4359        html_download_url: artifacts
4360            .html_path
4361            .as_ref()
4362            .map(|_| format!("/runs/html/{run_id}?download=1")),
4363        pdf_download_url: artifacts
4364            .pdf_path
4365            .as_ref()
4366            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4367        json_download_url: artifacts
4368            .json_path
4369            .as_ref()
4370            .map(|_| format!("/runs/json/{run_id}?download=1")),
4371        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4372        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4373        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4374        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4375        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4376        prev_fa_str,
4377        prev_fs_str,
4378        prev_pl_str,
4379        prev_cl_str,
4380        prev_cml_str,
4381        prev_bl_str,
4382        delta_fa_str,
4383        delta_fa_class,
4384        delta_fs_str,
4385        delta_fs_class,
4386        delta_pl_str,
4387        delta_pl_class,
4388        delta_cl_str,
4389        delta_cl_class,
4390        delta_cml_str,
4391        delta_cml_class,
4392        delta_bl_str,
4393        delta_bl_class,
4394        delta_lines_added,
4395        delta_lines_removed,
4396        delta_lines_net_str,
4397        delta_lines_net_class,
4398        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4399        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4400        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4401        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4402        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4403            d.file_deltas
4404                .iter()
4405                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4406                .map(|f| {
4407                    #[allow(clippy::cast_sign_loss)]
4408                    let n = f.current_code as u64;
4409                    n
4410                })
4411                .sum()
4412        }),
4413        git_branch,
4414        git_commit,
4415        git_commit_long,
4416        git_author,
4417        git_commit_url,
4418        scan_performed_by,
4419        scan_time_display,
4420        os_display,
4421        test_count,
4422        current_scan_number: prev_scan_count + 1,
4423        prev_scan_count,
4424        submodule_rows: run
4425            .submodule_summaries
4426            .iter()
4427            .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
4428            .collect(),
4429        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4430        scan_config_url: format!("/runs/scan-config/{run_id}"),
4431        lang_chart_json: {
4432            let entries: Vec<String> = run
4433                .totals_by_language
4434                .iter()
4435                .take(12)
4436                .map(|l| {
4437                    let name = l
4438                        .language
4439                        .display_name()
4440                        .replace('\\', "\\\\")
4441                        .replace('"', "\\\"");
4442                    format!(
4443                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4444                        name,
4445                        l.code_lines,
4446                        l.comment_lines,
4447                        l.blank_lines,
4448                        l.functions,
4449                        l.classes,
4450                        l.variables,
4451                        l.imports,
4452                        l.files,
4453                    )
4454                })
4455                .collect();
4456            format!("[{}]", entries.join(","))
4457        },
4458        scatter_chart_json: {
4459            let entries: Vec<String> = run
4460                .totals_by_language
4461                .iter()
4462                .map(|l| {
4463                    let name = l
4464                        .language
4465                        .display_name()
4466                        .replace('\\', "\\\\")
4467                        .replace('"', "\\\"");
4468                    format!(
4469                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4470                        name, l.files, l.code_lines, l.total_physical_lines,
4471                    )
4472                })
4473                .collect();
4474            format!("[{}]", entries.join(","))
4475        },
4476        semantic_chart_json: {
4477            let entries: Vec<String> = run
4478                .totals_by_language
4479                .iter()
4480                .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
4481                .map(|l| {
4482                    let name = l
4483                        .language
4484                        .display_name()
4485                        .replace('\\', "\\\\")
4486                        .replace('"', "\\\"");
4487                    format!(
4488                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
4489                        name, l.functions, l.classes, l.variables, l.imports,
4490                    )
4491                })
4492                .collect();
4493            format!("[{}]", entries.join(","))
4494        },
4495        submodule_chart_json: {
4496            let entries: Vec<String> = run
4497                .submodule_summaries
4498                .iter()
4499                .map(|s| {
4500                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
4501                    format!(
4502                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
4503                        name,
4504                        s.code_lines,
4505                        s.comment_lines,
4506                        s.blank_lines,
4507                        s.total_physical_lines,
4508                        s.files_analyzed,
4509                    )
4510                })
4511                .collect();
4512            format!("[{}]", entries.join(","))
4513        },
4514        has_submodule_data: !run.submodule_summaries.is_empty(),
4515        has_semantic_data: run
4516            .totals_by_language
4517            .iter()
4518            .any(|l| l.functions > 0 || l.classes > 0),
4519        csp_nonce: csp_nonce.to_owned(),
4520        confluence_configured,
4521        server_mode,
4522        report_header_footer: run
4523            .effective_configuration
4524            .reporting
4525            .report_header_footer
4526            .clone(),
4527    };
4528
4529    Html(
4530        template
4531            .render()
4532            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4533    )
4534    .into_response()
4535}
4536
4537fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
4538    let slug: String = report_title
4539        .chars()
4540        .map(|c| {
4541            if c.is_alphanumeric() || c == '-' {
4542                c.to_ascii_lowercase()
4543            } else {
4544                '_'
4545            }
4546        })
4547        .collect::<String>()
4548        .split('_')
4549        .filter(|s| !s.is_empty())
4550        .collect::<Vec<_>>()
4551        .join("_");
4552
4553    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
4554
4555    if slug.is_empty() {
4556        format!("report_{short_id}.pdf")
4557    } else {
4558        format!("{slug}_{short_id}.pdf")
4559    }
4560}
4561
4562#[derive(Serialize)]
4563struct PdfStatusResponse {
4564    ready: bool,
4565}
4566
4567/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
4568/// Clients poll this to update the button state without page reloads.
4569async fn pdf_status_handler(
4570    State(state): State<AppState>,
4571    AxumPath(run_id): AxumPath<String>,
4572) -> Response {
4573    let pdf_path = {
4574        let registry = state.artifacts.lock().await;
4575        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
4576    };
4577    let pdf_path = if pdf_path.is_some() {
4578        pdf_path
4579    } else {
4580        let reg = state.registry.lock().await;
4581        reg.find_by_run_id(&run_id)
4582            .map(recover_artifacts_from_registry)
4583            .and_then(|a| a.pdf_path)
4584    };
4585    let ready = pdf_path.is_some_and(|p| p.exists());
4586    Json(PdfStatusResponse { ready }).into_response()
4587}
4588
4589/// GET /`api/runs/:run_id/bundle`
4590///
4591/// Streams a gzip-compressed tar archive containing every artifact in the run's
4592/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
4593/// is built in memory so it never touches a temp file.
4594async fn download_bundle_handler(
4595    State(state): State<AppState>,
4596    AxumPath(run_id): AxumPath<String>,
4597) -> Response {
4598    // Resolve output directory from in-memory cache or persisted registry.
4599    let output_dir = {
4600        let cache = state.artifacts.lock().await;
4601        cache.get(&run_id).map(|a| a.output_dir.clone())
4602    };
4603    let output_dir = if let Some(d) = output_dir {
4604        d
4605    } else {
4606        let reg = state.registry.lock().await;
4607        match reg.find_by_run_id(&run_id) {
4608            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
4609            None => {
4610                return (
4611                    StatusCode::NOT_FOUND,
4612                    Json(serde_json::json!({"error": "Run not found"})),
4613                )
4614                    .into_response();
4615            }
4616        }
4617    };
4618
4619    if !output_dir.exists() {
4620        return (
4621            StatusCode::NOT_FOUND,
4622            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
4623        )
4624            .into_response();
4625    }
4626
4627    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
4628    let run_id_clone = run_id.clone();
4629    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
4630        use flate2::{write::GzEncoder, Compression};
4631        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
4632        {
4633            let mut tar = tar::Builder::new(&mut enc);
4634            tar.follow_symlinks(false);
4635            // Append every regular file in the output directory, skipping
4636            // sub-directories (the output dir is always flat).
4637            if let Ok(entries) = std::fs::read_dir(&output_dir) {
4638                for entry in entries.filter_map(Result::ok) {
4639                    let p = entry.path();
4640                    if p.is_file() {
4641                        let name = p.file_name().unwrap_or_default().to_string_lossy();
4642                        let archive_path = format!("{run_id_clone}/{name}");
4643                        tar.append_path_with_name(&p, &archive_path)?;
4644                    }
4645                }
4646            }
4647            tar.finish()?;
4648        }
4649        Ok(enc.finish()?)
4650    })
4651    .await;
4652
4653    match archive_result {
4654        Ok(Ok(bytes)) => {
4655            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
4656            axum::response::Response::builder()
4657                .status(StatusCode::OK)
4658                .header("Content-Type", "application/gzip")
4659                .header(
4660                    "Content-Disposition",
4661                    format!("attachment; filename=\"{filename}\""),
4662                )
4663                .header("Content-Length", bytes.len().to_string())
4664                .body(axum::body::Body::from(bytes))
4665                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
4666        }
4667        Ok(Err(e)) => (
4668            StatusCode::INTERNAL_SERVER_ERROR,
4669            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
4670        )
4671            .into_response(),
4672        Err(e) => (
4673            StatusCode::INTERNAL_SERVER_ERROR,
4674            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
4675        )
4676            .into_response(),
4677    }
4678}
4679
4680/// DELETE /`api/runs/:run_id`
4681///
4682/// Removes all on-disk artifacts for the run and purges the run from the
4683/// in-memory cache and the persisted registry. Returns 204 on success.
4684async fn delete_run_handler(
4685    State(state): State<AppState>,
4686    AxumPath(run_id): AxumPath<String>,
4687) -> Response {
4688    // Resolve output directory.
4689    let output_dir = {
4690        let mut cache = state.artifacts.lock().await;
4691        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
4692        cache.remove(&run_id);
4693        dir
4694    };
4695    let output_dir = if let Some(d) = output_dir {
4696        d
4697    } else {
4698        let reg = state.registry.lock().await;
4699        reg.find_by_run_id(&run_id)
4700            .map(|e| recover_artifacts_from_registry(e).output_dir)
4701            .unwrap_or_default()
4702    };
4703
4704    // Remove from persisted registry.
4705    {
4706        let mut reg = state.registry.lock().await;
4707        reg.entries.retain(|e| e.run_id != run_id);
4708        let _ = reg.save(&state.registry_path);
4709    }
4710
4711    // Delete on-disk artifacts.
4712    if output_dir.exists() {
4713        if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
4714            return (
4715                StatusCode::INTERNAL_SERVER_ERROR,
4716                Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
4717            )
4718                .into_response();
4719        }
4720    }
4721
4722    StatusCode::NO_CONTENT.into_response()
4723}
4724
4725/// POST /api/runs/cleanup
4726///
4727/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
4728/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
4729async fn cleanup_runs_handler(
4730    State(state): State<AppState>,
4731    Json(body): Json<serde_json::Value>,
4732) -> Response {
4733    let days = body
4734        .get("older_than_days")
4735        .and_then(serde_json::Value::as_u64)
4736        .unwrap_or(30)
4737        .max(1);
4738
4739    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
4740
4741    // Collect expired entries from the registry.
4742    let expired: Vec<(String, PathBuf)> = {
4743        let reg = state.registry.lock().await;
4744        reg.entries
4745            .iter()
4746            .filter(|e| e.timestamp_utc < cutoff)
4747            .map(|e| {
4748                let arts = recover_artifacts_from_registry(e);
4749                (e.run_id.clone(), arts.output_dir)
4750            })
4751            .collect()
4752    };
4753
4754    let mut deleted = 0usize;
4755    for (run_id, output_dir) in &expired {
4756        // Remove from in-memory cache.
4757        state.artifacts.lock().await.remove(run_id);
4758        // Delete on-disk artifacts (non-fatal if already gone).
4759        if output_dir.exists() {
4760            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
4761                eprintln!(
4762                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
4763                    output_dir.display()
4764                );
4765                continue;
4766            }
4767        }
4768        deleted += 1;
4769    }
4770
4771    // Purge expired run IDs from the registry in one pass.
4772    let expired_ids: std::collections::HashSet<&str> =
4773        expired.iter().map(|(id, _)| id.as_str()).collect();
4774    {
4775        let mut reg = state.registry.lock().await;
4776        reg.entries
4777            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
4778        let _ = reg.save(&state.registry_path);
4779    }
4780
4781    Json(serde_json::json!({ "deleted": deleted })).into_response()
4782}
4783
4784/// Serve the HTML artifact for a run — view or download.
4785/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
4786/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
4787/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
4788/// Only called for browser views; downloads keep the self-contained inline version.
4789fn swap_inline_chart_js_for_static(html: String) -> String {
4790    let Some(head_end) = html.find("</head>") else {
4791        return html;
4792    };
4793    let Some(script_start) = html[..head_end].rfind("<script") else {
4794        return html;
4795    };
4796    let Some(close_offset) = html[script_start..].find("</script>") else {
4797        return html;
4798    };
4799    let block_end = script_start + close_offset + "</script>".len();
4800    format!(
4801        "{}<script src=\"/static/chart-report.js\"></script>{}",
4802        &html[..script_start],
4803        &html[block_end..]
4804    )
4805}
4806
4807/// current-request Content-Security-Policy nonce check.
4808fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
4809    // Find the first nonce value that was baked in at render time.
4810    let Some(start) = html.find("nonce=\"") else {
4811        // Reports generated before nonce support was added have bare <style> and <script>
4812        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
4813        // the inline blocks — without it the browser blocks all CSS and JS.
4814        return html
4815            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
4816            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
4817    };
4818    let value_start = start + 7; // len(r#"nonce=""#) == 7
4819    let Some(end_offset) = html[value_start..].find('"') else {
4820        return html.to_owned();
4821    };
4822    let old_nonce = &html[value_start..value_start + end_offset];
4823    html.replace(
4824        &format!("nonce=\"{old_nonce}\""),
4825        &format!("nonce=\"{new_nonce}\""),
4826    )
4827}
4828
4829fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4830    match fs::read_to_string(path) {
4831        Ok(raw) => {
4832            // Patch the saved nonce so inline styles/scripts pass CSP.
4833            let content = patch_html_nonce(&raw, csp_nonce);
4834            if wants_download {
4835                // Keep the self-contained inline version for downloads (opened as file://).
4836                (
4837                    [
4838                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
4839                        (
4840                            header::CONTENT_DISPOSITION,
4841                            "attachment; filename=report.html",
4842                        ),
4843                    ],
4844                    content,
4845                )
4846                    .into_response()
4847            } else {
4848                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
4849                // browser caches it after the first view; the HTML response also shrinks.
4850                Html(swap_inline_chart_js_for_static(content)).into_response()
4851            }
4852        }
4853        Err(err) => {
4854            let filename = path.file_name().map_or_else(
4855                || "report.html".to_string(),
4856                |n| n.to_string_lossy().into_owned(),
4857            );
4858            let msg = format!(
4859                "HTML report '{filename}' could not be read.\n\n\
4860                 Error: {err}\n\n\
4861                 If you moved or renamed the output folder, the stored path is now stale. \
4862                 Use 'Open HTML folder' from the results page to browse the output directory."
4863            );
4864            let html = ErrorTemplate {
4865                message: msg,
4866                last_report_url: Some("/view-reports".to_string()),
4867                last_report_label: Some("View Reports".to_string()),
4868                run_id: None,
4869                error_code: Some(404),
4870                csp_nonce: csp_nonce.to_owned(),
4871                version: env!("CARGO_PKG_VERSION"),
4872            }
4873            .render()
4874            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4875            (StatusCode::NOT_FOUND, Html(html)).into_response()
4876        }
4877    }
4878}
4879
4880/// Serve the PDF artifact for a run — inline or download.
4881fn serve_pdf_artifact(
4882    path: &Path,
4883    report_title: &str,
4884    run_id: &str,
4885    wants_download: bool,
4886    csp_nonce: &str,
4887) -> Response {
4888    match fs::read(path) {
4889        Ok(bytes) => {
4890            let filename = build_pdf_filename(report_title, run_id);
4891            let disposition = if wants_download {
4892                format!("attachment; filename=\"{filename}\"")
4893            } else {
4894                format!("inline; filename=\"{filename}\"")
4895            };
4896            (
4897                [
4898                    (header::CONTENT_TYPE, "application/pdf".to_string()),
4899                    (header::CONTENT_DISPOSITION, disposition),
4900                ],
4901                bytes,
4902            )
4903                .into_response()
4904        }
4905        Err(err) => {
4906            let filename = path.file_name().map_or_else(
4907                || "report.pdf".to_string(),
4908                |n| n.to_string_lossy().into_owned(),
4909            );
4910            let msg = format!(
4911                "PDF report '{filename}' could not be read.\n\n\
4912                 Error: {err}\n\n\
4913                 If you moved or renamed the output folder, the stored path is now stale. \
4914                 Use 'Open PDF folder' from the results page to browse the output directory."
4915            );
4916            let html = ErrorTemplate {
4917                message: msg,
4918                last_report_url: Some("/view-reports".to_string()),
4919                last_report_label: Some("View Reports".to_string()),
4920                run_id: Some(run_id.to_owned()),
4921                error_code: Some(404),
4922                csp_nonce: csp_nonce.to_owned(),
4923                version: env!("CARGO_PKG_VERSION"),
4924            }
4925            .render()
4926            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4927            (StatusCode::NOT_FOUND, Html(html)).into_response()
4928        }
4929    }
4930}
4931
4932/// Serve the JSON artifact for a run — view or download.
4933fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4934    match fs::read(path) {
4935        Ok(bytes) => {
4936            if wants_download {
4937                (
4938                    [
4939                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
4940                        (
4941                            header::CONTENT_DISPOSITION,
4942                            "attachment; filename=result.json",
4943                        ),
4944                    ],
4945                    bytes,
4946                )
4947                    .into_response()
4948            } else {
4949                (
4950                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
4951                    bytes,
4952                )
4953                    .into_response()
4954            }
4955        }
4956        Err(err) => {
4957            let filename = path.file_name().map_or_else(
4958                || "result.json".to_string(),
4959                |n| n.to_string_lossy().into_owned(),
4960            );
4961            let msg = format!(
4962                "JSON result '{filename}' could not be read.\n\n\
4963                 Error: {err}\n\n\
4964                 If you moved or renamed the output folder, the stored path is now stale. \
4965                 Use 'Open JSON folder' from the results page to browse the output directory."
4966            );
4967            let html = ErrorTemplate {
4968                message: msg,
4969                last_report_url: Some("/view-reports".to_string()),
4970                last_report_label: Some("View Reports".to_string()),
4971                run_id: None,
4972                error_code: Some(404),
4973                csp_nonce: csp_nonce.to_owned(),
4974                version: env!("CARGO_PKG_VERSION"),
4975            }
4976            .render()
4977            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4978            (StatusCode::NOT_FOUND, Html(html)).into_response()
4979        }
4980    }
4981}
4982
4983/// Recover a `RunArtifacts` from the persisted registry for a run ID.
4984fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
4985    let output_dir = entry
4986        .html_path
4987        .as_ref()
4988        .or(entry.json_path.as_ref())
4989        .or(entry.pdf_path.as_ref())
4990        .or(entry.csv_path.as_ref())
4991        .or(entry.xlsx_path.as_ref())
4992        .and_then(|p| p.parent().map(PathBuf::from))
4993        .unwrap_or_default();
4994    // Recover pdf_path: use the persisted one, or look for report.pdf
4995    // adjacent to html/json if only the old entries lack it.
4996    let pdf_path = entry.pdf_path.clone().or_else(|| {
4997        let candidate = output_dir.join("report.pdf");
4998        candidate.exists().then_some(candidate)
4999    });
5000    // csv_path / xlsx_path: persisted paths take precedence; fall back to
5001    // scanning the run directory for files matching the expected patterns so
5002    // that runs created before this feature still surface their artifacts.
5003    let csv_path = entry.csv_path.clone().or_else(|| {
5004        fs::read_dir(&output_dir).ok().and_then(|entries| {
5005            entries
5006                .filter_map(std::result::Result::ok)
5007                .find(|e| {
5008                    let n = e.file_name();
5009                    let n = n.to_string_lossy();
5010                    n.starts_with("report_") && n.ends_with(".csv")
5011                })
5012                .map(|e| e.path())
5013        })
5014    });
5015    let xlsx_path = entry.xlsx_path.clone().or_else(|| {
5016        fs::read_dir(&output_dir).ok().and_then(|entries| {
5017            entries
5018                .filter_map(std::result::Result::ok)
5019                .find(|e| {
5020                    let n = e.file_name();
5021                    let n = n.to_string_lossy();
5022                    n.starts_with("report_") && n.ends_with(".xlsx")
5023                })
5024                .map(|e| e.path())
5025        })
5026    });
5027    RunArtifacts {
5028        output_dir: output_dir.clone(),
5029        html_path: entry.html_path.clone(),
5030        pdf_path,
5031        json_path: entry.json_path.clone(),
5032        csv_path,
5033        xlsx_path,
5034        scan_config_path: find_scan_config_in_dir(&output_dir),
5035        report_title: entry.project_label.clone(),
5036        result_context: RunResultContext::default(),
5037    }
5038}
5039
5040#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
5041async fn resolve_artifact_set(
5042    state: &AppState,
5043    run_id: &str,
5044    csp_nonce: &str,
5045) -> Result<RunArtifacts, Response> {
5046    let cached = state.artifacts.lock().await.get(run_id).cloned();
5047    if let Some(a) = cached {
5048        return Ok(a);
5049    }
5050    let reg = state.registry.lock().await;
5051    if let Some(entry) = reg.find_by_run_id(run_id) {
5052        return Ok(recover_artifacts_from_registry(entry));
5053    }
5054    drop(reg);
5055    let short_id = &run_id[..run_id.len().min(8)];
5056    let hint = if matches!(
5057        run_id,
5058        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
5059    ) {
5060        format!(
5061            " The URL format appears to be reversed — \
5062             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
5063             Use the View Reports page to navigate to your scan."
5064        )
5065    } else {
5066        " The report may have been deleted or the report directory moved. \
5067         Use View Reports to browse your scan history."
5068            .to_string()
5069    };
5070    let error_html = ErrorTemplate {
5071        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
5072        last_report_url: Some("/view-reports".to_string()),
5073        last_report_label: Some("View Reports".to_string()),
5074        run_id: None,
5075        error_code: Some(404),
5076        csp_nonce: csp_nonce.to_owned(),
5077        version: env!("CARGO_PKG_VERSION"),
5078    }
5079    .render()
5080    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5081    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
5082}
5083
5084/// Return the path to a run's PDF, queuing background generation when it is missing.
5085///
5086/// Returns `Ok(path)` when the PDF is known (it may still be generating).
5087/// Returns `Err(response)` when there is no JSON source to regenerate from.
5088async fn resolve_or_queue_pdf(
5089    state: &AppState,
5090    pdf_path: Option<PathBuf>,
5091    json_path: Option<PathBuf>,
5092    output_dir: PathBuf,
5093    run_id: &str,
5094    report_title: &str,
5095    csp_nonce: &str,
5096) -> Result<PathBuf, Response> {
5097    if let Some(p) = pdf_path {
5098        return Ok(p);
5099    }
5100    let Some(json_src) = json_path.filter(|p| p.exists()) else {
5101        let msg = "PDF report was not generated for this run. \
5102                   Re-run the analysis with PDF output enabled."
5103            .to_string();
5104        let html = ErrorTemplate {
5105            message: msg,
5106            last_report_url: Some(format!("/runs/html/{run_id}")),
5107            last_report_label: Some("View HTML Report".to_string()),
5108            run_id: Some(run_id.to_string()),
5109            error_code: Some(404),
5110            csp_nonce: csp_nonce.to_string(),
5111            version: env!("CARGO_PKG_VERSION"),
5112        }
5113        .render()
5114        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
5115        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5116    };
5117    let pdf_filename = build_pdf_filename(report_title, run_id);
5118    let pdf_dest = output_dir.join(&pdf_filename);
5119    if !pdf_dest.exists() {
5120        // Record the pending path so concurrent requests show the spinner.
5121        {
5122            let mut map = state.artifacts.lock().await;
5123            if let Some(entry) = map.get_mut(run_id) {
5124                entry.pdf_path = Some(pdf_dest.clone());
5125            }
5126        }
5127        {
5128            let mut reg = state.registry.lock().await;
5129            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
5130                e.pdf_path = Some(pdf_dest.clone());
5131            }
5132            let _ = reg.save(&state.registry_path);
5133        }
5134        spawn_native_pdf_background(
5135            json_src,
5136            pdf_dest.clone(),
5137            run_id.to_string(),
5138            state.artifacts.clone(),
5139        );
5140    }
5141    Ok(pdf_dest)
5142}
5143
5144/// Self-refreshing "please wait" page shown while the background PDF task is still running.
5145fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
5146    let html = format!(
5147                    "<!doctype html><html lang=\"en\"><head>\
5148                     <meta charset=utf-8>\
5149                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
5150                     <meta http-equiv=\"refresh\" content=\"5\">\
5151                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
5152                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
5153                     <style nonce=\"{csp_nonce}\">\
5154                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
5155                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
5156                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
5157                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
5158                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
5159                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
5160                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
5161                     background:var(--bg);color:var(--text);}}\
5162                     .top-nav{{position:sticky;top:0;z-index:30;\
5163                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
5164                     border-bottom:1px solid rgba(255,255,255,0.12);\
5165                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
5166                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
5167                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
5168                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
5169                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
5170                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
5171                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
5172                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
5173                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
5174                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
5175                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
5176                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
5177                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
5178                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
5179                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
5180                     justify-content:center;min-height:38px;border-radius:999px;\
5181                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
5182                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
5183                     .theme-toggle .icon-sun{{display:none;}}\
5184                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
5185                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
5186                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
5187                     display:flex;align-items:center;justify-content:center;\
5188                     min-height:calc(100vh - 56px);}}\
5189                     .panel{{background:var(--surface);border:1px solid var(--line);\
5190                     border-radius:var(--radius);box-shadow:var(--shadow);\
5191                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
5192                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
5193                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
5194                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
5195                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
5196                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
5197                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
5198                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
5199                     min-height:42px;padding:0 20px;border-radius:14px;\
5200                     border:1px solid var(--line-strong);text-decoration:none;\
5201                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
5202                     .back-link:hover{{background:var(--line);}}\
5203                     </style></head>\
5204                     <body>\
5205                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
5206                       <a class=\"brand\" href=\"/\">\
5207                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
5208                         <div class=\"brand-copy\">\
5209                           <div class=\"brand-title\">OxideSLOC</div>\
5210                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
5211                         </div>\
5212                       </a>\
5213                       <div class=\"nav-right\">\
5214                         <a class=\"nav-pill\" href=\"/\">Home</a>\
5215                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
5216                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
5217                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5218                           <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>\
5219                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5220                           <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>\
5221                         </button>\
5222                       </div>\
5223                     </div></div>\
5224                     <div class=\"page\"><div class=\"panel\">\
5225                       <div class=\"spin-ring\"></div>\
5226                       <h1>Generating PDF\u{2026}</h1>\
5227                       <p>The PDF is being generated from the scan results.<br>\
5228                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
5229                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5230                     </div></div>\
5231                     <script nonce=\"{csp_nonce}\">\
5232                     (function(){{\
5233                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5234                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
5235                       var t=document.getElementById(\"theme-toggle\");\
5236                       if(t)t.addEventListener(\"click\",function(){{\
5237                         var d=b.classList.toggle(\"dark-theme\");\
5238                         localStorage.setItem(k,d?\"dark\":\"light\");\
5239                       }});\
5240                     }})();\
5241                     </script>\
5242                     </body></html>"
5243    );
5244    Html(html).into_response()
5245}
5246
5247async fn artifact_handler(
5248    State(state): State<AppState>,
5249    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5250    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
5251    Query(query): Query<ArtifactQuery>,
5252) -> Response {
5253    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
5254        Ok(a) => a,
5255        Err(r) => return r,
5256    };
5257
5258    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
5259
5260    match artifact.as_str() {
5261        "html" => {
5262            let Some(path) = artifact_set.html_path else {
5263                return StatusCode::NOT_FOUND.into_response();
5264            };
5265            serve_html_artifact(&path, wants_download, &csp_nonce)
5266        }
5267        "pdf" => {
5268            let report_title = artifact_set.report_title.clone();
5269            let path = match resolve_or_queue_pdf(
5270                &state,
5271                artifact_set.pdf_path,
5272                artifact_set.json_path.clone(),
5273                artifact_set.output_dir.clone(),
5274                &run_id,
5275                &report_title,
5276                &csp_nonce,
5277            )
5278            .await
5279            {
5280                Ok(p) => p,
5281                Err(r) => return r,
5282            };
5283            // PDF path is recorded but the background task may still be writing it.
5284            // Return a self-refreshing "please wait" page rather than an error.
5285            if !path.exists() {
5286                return pdf_generating_response(&run_id, &csp_nonce);
5287            }
5288            serve_pdf_artifact(&path, &report_title, &run_id, wants_download, &csp_nonce)
5289        }
5290        "json" => {
5291            let Some(path) = artifact_set.json_path else {
5292                let msg = "JSON result was not generated for this run, or was not recorded in \
5293                           the scan registry. Re-run the analysis with JSON output enabled."
5294                    .to_string();
5295                let html = ErrorTemplate {
5296                    message: msg,
5297                    last_report_url: Some("/view-reports".to_string()),
5298                    last_report_label: Some("View Reports".to_string()),
5299                    run_id: Some(run_id.clone()),
5300                    error_code: Some(404),
5301                    csp_nonce: csp_nonce.clone(),
5302                    version: env!("CARGO_PKG_VERSION"),
5303                }
5304                .render()
5305                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
5306                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5307            };
5308            serve_json_artifact(&path, wants_download, &csp_nonce)
5309        }
5310        "csv" => {
5311            let Some(path) = artifact_set.csv_path else {
5312                let msg = "CSV report was not generated for this run, or was not recorded in \
5313                           the scan registry."
5314                    .to_string();
5315                let html = ErrorTemplate {
5316                    message: msg,
5317                    last_report_url: Some(format!("/runs/html/{run_id}")),
5318                    last_report_label: Some("View HTML Report".to_string()),
5319                    run_id: Some(run_id.clone()),
5320                    error_code: Some(404),
5321                    csp_nonce: csp_nonce.clone(),
5322                    version: env!("CARGO_PKG_VERSION"),
5323                }
5324                .render()
5325                .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
5326                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5327            };
5328            fs::read(&path).map_or_else(
5329                |_| StatusCode::NOT_FOUND.into_response(),
5330                |bytes| {
5331                    let filename = path.file_name().map_or_else(
5332                        || "report.csv".to_string(),
5333                        |n| n.to_string_lossy().into_owned(),
5334                    );
5335                    (
5336                        [
5337                            (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
5338                            (
5339                                header::CONTENT_DISPOSITION,
5340                                format!("attachment; filename=\"{filename}\""),
5341                            ),
5342                        ],
5343                        bytes,
5344                    )
5345                        .into_response()
5346                },
5347            )
5348        }
5349        "xlsx" => {
5350            let Some(path) = artifact_set.xlsx_path else {
5351                let msg = "Excel report was not generated for this run, or was not recorded in \
5352                           the scan registry."
5353                    .to_string();
5354                let html = ErrorTemplate {
5355                    message: msg,
5356                    last_report_url: Some(format!("/runs/html/{run_id}")),
5357                    last_report_label: Some("View HTML Report".to_string()),
5358                    run_id: Some(run_id.clone()),
5359                    error_code: Some(404),
5360                    csp_nonce: csp_nonce.clone(),
5361                    version: env!("CARGO_PKG_VERSION"),
5362                }
5363                .render()
5364                .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
5365                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5366            };
5367            fs::read(&path).map_or_else(
5368                |_| StatusCode::NOT_FOUND.into_response(),
5369                |bytes| {
5370                    let filename = path.file_name().map_or_else(
5371                        || "report.xlsx".to_string(),
5372                        |n| n.to_string_lossy().into_owned(),
5373                    );
5374                    (
5375                        [
5376                            (
5377                                header::CONTENT_TYPE,
5378                                "application/vnd.openxmlformats-officedocument\
5379                                 .spreadsheetml.sheet"
5380                                    .to_string(),
5381                            ),
5382                            (
5383                                header::CONTENT_DISPOSITION,
5384                                format!("attachment; filename=\"{filename}\""),
5385                            ),
5386                        ],
5387                        bytes,
5388                    )
5389                        .into_response()
5390                },
5391            )
5392        }
5393        "scan-config" => {
5394            let path = artifact_set
5395                .scan_config_path
5396                .as_deref()
5397                .map(std::path::Path::to_path_buf)
5398                .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
5399                .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
5400            fs::read(&path).map_or_else(
5401                |_| StatusCode::NOT_FOUND.into_response(),
5402                |bytes| {
5403                    (
5404                        [
5405                            (
5406                                header::CONTENT_TYPE,
5407                                "application/json; charset=utf-8".to_string(),
5408                            ),
5409                            (
5410                                header::CONTENT_DISPOSITION,
5411                                "attachment; filename=\"scan-config.json\"".to_string(),
5412                            ),
5413                        ],
5414                        bytes,
5415                    )
5416                        .into_response()
5417                },
5418            )
5419        }
5420        _ if artifact.starts_with("sub_") => {
5421            if artifact.len() > 128
5422                || !artifact
5423                    .chars()
5424                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
5425            {
5426                return StatusCode::BAD_REQUEST.into_response();
5427            }
5428            let filename = format!("{artifact}.html");
5429            let path = artifact_set.output_dir.join(&filename);
5430            if !path.exists() {
5431                let html = ErrorTemplate {
5432                    message: format!(
5433                        "Sub-report '{artifact}' was not found in the run directory.\n\
5434                         Re-run the analysis with 'Detect and separate git submodules' \
5435                         and HTML output enabled."
5436                    ),
5437                    last_report_url: Some("/view-reports".to_string()),
5438                    last_report_label: Some("View Reports".to_string()),
5439                    run_id: Some(run_id.clone()),
5440                    error_code: Some(404),
5441                    csp_nonce: csp_nonce.clone(),
5442                    version: env!("CARGO_PKG_VERSION"),
5443                }
5444                .render()
5445                .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
5446                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5447            }
5448            serve_html_artifact(&path, wants_download, &csp_nonce)
5449        }
5450        _ => StatusCode::NOT_FOUND.into_response(),
5451    }
5452}
5453
5454// ── History ───────────────────────────────────────────────────────────────────
5455
5456struct SubmoduleLinkRow {
5457    name: String,
5458    url: String,
5459}
5460
5461struct HistoryEntryRow {
5462    run_id: String,
5463    run_id_short: String,
5464    timestamp: String,
5465    timestamp_utc_ms: i64,
5466    project_label: String,
5467    project_path: String,
5468    files_analyzed: u64,
5469    files_skipped: u64,
5470    code_lines: u64,
5471    comment_lines: u64,
5472    blank_lines: u64,
5473    git_branch: String,
5474    git_commit: String,
5475    has_html: bool,
5476    has_json: bool,
5477    has_pdf: bool,
5478    submodule_links: Vec<SubmoduleLinkRow>,
5479    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
5480    submodule_names_csv: String,
5481}
5482
5483/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
5484fn nth_weekday_of_month(
5485    year: i32,
5486    month: u32,
5487    weekday: chrono::Weekday,
5488    n: u32,
5489) -> chrono::NaiveDate {
5490    use chrono::Datelike;
5491    let mut count = 0u32;
5492    let mut day = 1u32;
5493    loop {
5494        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
5495        if d.weekday() == weekday {
5496            count += 1;
5497            if count == n {
5498                return d;
5499            }
5500        }
5501        day += 1;
5502    }
5503}
5504
5505/// Returns true if `dt` falls within US Pacific Daylight Time.
5506/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
5507/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
5508fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
5509    use chrono::{Datelike, TimeZone};
5510    let year = dt.year();
5511    let dst_start = chrono::Utc.from_utc_datetime(
5512        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
5513            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
5514    );
5515    let dst_end = chrono::Utc.from_utc_datetime(
5516        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
5517            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
5518    );
5519    dt >= dst_start && dt < dst_end
5520}
5521
5522fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
5523    if is_pacific_dst(dt) {
5524        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
5525            .format("%Y-%m-%d %H:%M PDT")
5526            .to_string()
5527    } else {
5528        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
5529            .format("%Y-%m-%d %H:%M PST")
5530            .to_string()
5531    }
5532}
5533
5534/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
5535fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
5536    let (offset, tz) = if is_pacific_dst(dt) {
5537        (
5538            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
5539            "PDT",
5540        )
5541    } else {
5542        (
5543            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
5544            "PST",
5545        )
5546    };
5547    format!(
5548        "{} {tz}",
5549        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
5550    )
5551}
5552
5553fn fmt_git_date(iso: &str) -> Option<String> {
5554    chrono::DateTime::parse_from_rfc3339(iso)
5555        .ok()
5556        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
5557}
5558
5559fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
5560    reg.entries
5561        .iter()
5562        .map(|e| {
5563            let submodule_links = {
5564                let mut links: Vec<SubmoduleLinkRow> = vec![];
5565                let sub_dir = e
5566                    .html_path
5567                    .as_ref()
5568                    .and_then(|p| p.parent())
5569                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5570                if let Some(dir) = sub_dir {
5571                    if let Ok(rd) = std::fs::read_dir(dir) {
5572                        for entry_res in rd.flatten() {
5573                            let fname = entry_res.file_name();
5574                            let fname_str = fname.to_string_lossy();
5575                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5576                                let stem = &fname_str[..fname_str.len() - 5];
5577                                let display = stem[4..].replace('-', " ");
5578                                links.push(SubmoduleLinkRow {
5579                                    name: display,
5580                                    url: format!("/runs/{stem}/{}", e.run_id),
5581                                });
5582                            }
5583                        }
5584                    }
5585                }
5586                links.sort_by(|a, b| a.name.cmp(&b.name));
5587                links
5588            };
5589            let submodule_names_csv = submodule_links
5590                .iter()
5591                .map(|l| l.name.as_str())
5592                .collect::<Vec<_>>()
5593                .join(",");
5594            HistoryEntryRow {
5595                run_id: e.run_id.clone(),
5596                run_id_short: e
5597                    .run_id
5598                    .split('-')
5599                    .next_back()
5600                    .unwrap_or(&e.run_id)
5601                    .chars()
5602                    .take(7)
5603                    .collect(),
5604                timestamp: fmt_la_time(e.timestamp_utc),
5605                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
5606                project_label: e.project_label.clone(),
5607                project_path: e
5608                    .input_roots
5609                    .first()
5610                    .map(|s| sanitize_path_str(s))
5611                    .unwrap_or_default(),
5612                files_analyzed: e.summary.files_analyzed,
5613                files_skipped: e.summary.files_skipped,
5614                code_lines: e.summary.code_lines,
5615                comment_lines: e.summary.comment_lines,
5616                blank_lines: e.summary.blank_lines,
5617                git_branch: e.git_branch.clone().unwrap_or_default(),
5618                git_commit: e.git_commit.clone().unwrap_or_default(),
5619                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
5620                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
5621                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
5622                submodule_links,
5623                submodule_names_csv,
5624            }
5625        })
5626        .collect()
5627}
5628
5629#[derive(Deserialize, Default)]
5630struct HistoryQuery {
5631    linked: Option<String>,
5632    error: Option<String>,
5633}
5634
5635async fn history_handler(
5636    State(state): State<AppState>,
5637    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5638    Query(query): Query<HistoryQuery>,
5639) -> impl IntoResponse {
5640    // Auto-scan all watched directories before rendering so the list stays fresh.
5641    auto_scan_watched_dirs(&state).await;
5642    let watched_dirs: Vec<String> = {
5643        let wd = state.watched_dirs.lock().await;
5644        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5645    };
5646    let mut entries = {
5647        let reg = state.registry.lock().await;
5648        make_history_rows(&reg)
5649    };
5650    entries.retain(|e| e.has_html);
5651    let total_scans = entries.len();
5652    let linked_count = query
5653        .linked
5654        .as_deref()
5655        .and_then(|s| s.parse::<usize>().ok())
5656        .unwrap_or(0);
5657    let browse_error = query.error.filter(|s| !s.is_empty());
5658    let template = HistoryTemplate {
5659        version: env!("CARGO_PKG_VERSION"),
5660        entries,
5661        total_scans,
5662        linked_count,
5663        browse_error,
5664        watched_dirs,
5665        csp_nonce,
5666        server_mode: state.server_mode,
5667    };
5668    Html(
5669        template
5670            .render()
5671            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5672    )
5673    .into_response()
5674}
5675
5676async fn compare_select_handler(
5677    State(state): State<AppState>,
5678    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5679) -> impl IntoResponse {
5680    auto_scan_watched_dirs(&state).await;
5681    let watched_dirs: Vec<String> = {
5682        let wd = state.watched_dirs.lock().await;
5683        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5684    };
5685    let mut entries = {
5686        let reg = state.registry.lock().await;
5687        make_history_rows(&reg)
5688    };
5689    entries.retain(|e| e.has_json);
5690    let total_scans = entries.len();
5691    let template = CompareSelectTemplate {
5692        version: env!("CARGO_PKG_VERSION"),
5693        entries,
5694        total_scans,
5695        watched_dirs,
5696        csp_nonce,
5697        server_mode: state.server_mode,
5698    };
5699    Html(
5700        template
5701            .render()
5702            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5703    )
5704    .into_response()
5705}
5706
5707// ── Compare ───────────────────────────────────────────────────────────────────
5708
5709#[derive(Deserialize, Default)]
5710struct CompareQuery {
5711    a: Option<String>,
5712    b: Option<String>,
5713    /// Optional submodule name to scope the comparison to one submodule.
5714    sub: Option<String>,
5715    /// "super" to exclude all submodule files and show only the super-repo.
5716    scope: Option<String>,
5717}
5718
5719struct CompareFileDeltaRow {
5720    relative_path: String,
5721    language: String,
5722    status: String,
5723    baseline_code: i64,
5724    current_code: i64,
5725    code_delta_str: String,
5726    code_delta_class: String,
5727    comment_delta_str: String,
5728    comment_delta_class: String,
5729    total_delta_str: String,
5730    total_delta_class: String,
5731}
5732
5733/// Recompute `summary_totals` from the current `per_file_records` slice.
5734/// Used when `per_file_records` has been narrowed to a submodule subset.
5735fn recompute_summary_from_records(run: &mut AnalysisRun) {
5736    let files_analyzed = run
5737        .per_file_records
5738        .iter()
5739        .filter(|r| r.language.is_some())
5740        .count() as u64;
5741    let code_lines: u64 = run
5742        .per_file_records
5743        .iter()
5744        .map(|r| r.effective_counts.code_lines)
5745        .sum();
5746    let comment_lines: u64 = run
5747        .per_file_records
5748        .iter()
5749        .map(|r| r.effective_counts.comment_lines)
5750        .sum();
5751    let blank_lines: u64 = run
5752        .per_file_records
5753        .iter()
5754        .map(|r| r.effective_counts.blank_lines)
5755        .sum();
5756    run.summary_totals.files_analyzed = files_analyzed;
5757    run.summary_totals.files_considered = files_analyzed;
5758    run.summary_totals.code_lines = code_lines;
5759    run.summary_totals.comment_lines = comment_lines;
5760    run.summary_totals.blank_lines = blank_lines;
5761    run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
5762}
5763
5764fn fmt_delta(n: i64) -> String {
5765    if n > 0 {
5766        format!("+{n}")
5767    } else {
5768        format!("{n}")
5769    }
5770}
5771
5772fn delta_class(n: i64) -> &'static str {
5773    use std::cmp::Ordering;
5774    match n.cmp(&0) {
5775        Ordering::Greater => "pos",
5776        Ordering::Less => "neg",
5777        Ordering::Equal => "zero",
5778    }
5779}
5780
5781// ratio/percentage display, precision loss acceptable
5782#[allow(clippy::cast_precision_loss)]
5783fn fmt_pct(delta: i64, baseline: u64) -> String {
5784    if baseline == 0 {
5785        return "—".to_string();
5786    }
5787    #[allow(clippy::cast_precision_loss)]
5788    let pct = (delta as f64 / baseline as f64) * 100.0;
5789    if pct > 0.049 {
5790        format!("+{pct:.1}%")
5791    } else if pct < -0.049 {
5792        format!("{pct:.1}%")
5793    } else {
5794        "±0%".to_string()
5795    }
5796}
5797
5798/// Returns (`display_string`, `css_class`) for a numeric change column cell.
5799fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
5800    prev.map_or_else(
5801        || ("—".to_string(), "na"),
5802        |p| {
5803            #[allow(clippy::cast_possible_wrap)]
5804            let d = curr as i64 - p as i64;
5805            (fmt_delta(d), delta_class(d))
5806        },
5807    )
5808}
5809
5810#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
5811fn load_scan_for_compare(
5812    json_path: &std::path::Path,
5813    scan_label: &str,
5814    run_id: &str,
5815    server_mode: bool,
5816    compare_url: &str,
5817    csp_nonce: &str,
5818) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
5819    match read_json(json_path) {
5820        Ok(r) => Ok(r),
5821        Err(e) => {
5822            if server_mode {
5823                let html = ErrorTemplate {
5824                    message: format!(
5825                        "Could not load {scan_label} scan data. The scan output folder may have \
5826                         been moved, renamed, or deleted. Re-running the analysis will create \
5827                         fresh comparison data."
5828                    ),
5829                    last_report_url: Some("/compare-scans".to_string()),
5830                    last_report_label: Some("Compare Scans".to_string()),
5831                    run_id: Some(run_id.to_owned()),
5832                    error_code: Some(404),
5833                    csp_nonce: csp_nonce.to_owned(),
5834                    version: env!("CARGO_PKG_VERSION"),
5835                }
5836                .render()
5837                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
5838                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5839            }
5840            let msg = format!(
5841                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
5842                json_path.display()
5843            );
5844            let folder_hint = json_path
5845                .parent()
5846                .map(|p| p.display().to_string())
5847                .unwrap_or_default();
5848            Err(missing_scan_relocate_response(
5849                &msg,
5850                run_id,
5851                &folder_hint,
5852                compare_url,
5853                false,
5854                csp_nonce,
5855            ))
5856        }
5857    }
5858}
5859
5860struct ChurnStats {
5861    new_scope: bool,
5862    scope_flag: bool,
5863    churn_rate_str: String,
5864    churn_rate_class: String,
5865}
5866
5867fn compute_churn_stats(
5868    baseline_code: u64,
5869    current_code: u64,
5870    lines_added: i64,
5871    lines_removed: i64,
5872) -> ChurnStats {
5873    let new_scope = baseline_code == 0 && current_code > 0;
5874    #[allow(clippy::cast_precision_loss)]
5875    let churn_pct = if baseline_code > 0 {
5876        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
5877    } else {
5878        0.0
5879    };
5880    #[allow(clippy::cast_precision_loss)]
5881    let scope_flag =
5882        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
5883    let churn_rate_str = if new_scope {
5884        "New".to_string()
5885    } else if baseline_code > 0 {
5886        format!("{churn_pct:.1}%")
5887    } else {
5888        "—".to_string()
5889    };
5890    let churn_rate_class = if new_scope || churn_pct > 20.0 {
5891        "high".to_string()
5892    } else if churn_pct > 5.0 {
5893        "med".to_string()
5894    } else {
5895        "low".to_string()
5896    };
5897    ChurnStats {
5898        new_scope,
5899        scope_flag,
5900        churn_rate_str,
5901        churn_rate_class,
5902    }
5903}
5904
5905/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
5906/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
5907/// variables to the large CompareTemplate, which causes rustc stack overflows on Windows.
5908fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
5909    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
5910    if !has_data {
5911        return String::new();
5912    }
5913    let base_str = s
5914        .baseline_coverage_line_pct
5915        .map(|p| format!("{p:.1}%"))
5916        .unwrap_or_else(|| "\u{2014}".into());
5917    let curr_str = s
5918        .current_coverage_line_pct
5919        .map(|p| format!("{p:.1}%"))
5920        .unwrap_or_else(|| "\u{2014}".into());
5921    let (delta_str, cls) = match s.coverage_line_pct_delta {
5922        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
5923        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
5924        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
5925        None => ("\u{2014}".into(), "zero"),
5926    };
5927    format!(
5928        r#"<div class="delta-card">
5929          <div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo. Positive delta = more lines instrumented and hit. Only shown when at least one scan has coverage data.</div>
5930          <div class="delta-card-label">Line coverage</div>
5931          <div class="delta-card-from">Before: {base_str}</div>
5932          <div class="delta-card-to">{curr_str}</div>
5933          <span class="delta-card-change {cls}">{delta_str}</span>
5934        </div>"#
5935    )
5936}
5937
5938#[allow(clippy::too_many_lines)]
5939async fn compare_handler(
5940    State(state): State<AppState>,
5941    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5942    Query(query): Query<CompareQuery>,
5943) -> impl IntoResponse {
5944    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
5945    // redirect to the history page where the user can select two runs.
5946    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
5947        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
5948        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
5949    };
5950
5951    let (maybe_a, maybe_b) = {
5952        let reg = state.registry.lock().await;
5953        (
5954            reg.find_by_run_id(&run_id_a).cloned(),
5955            reg.find_by_run_id(&run_id_b).cloned(),
5956        )
5957    };
5958
5959    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
5960        let html = ErrorTemplate {
5961            message: "One or both run IDs were not found in scan history. \
5962                      The runs may have been deleted or the registry may have been reset."
5963                .to_string(),
5964            last_report_url: Some("/compare-scans".to_string()),
5965            last_report_label: Some("Compare Scans".to_string()),
5966            run_id: None,
5967            error_code: None,
5968            csp_nonce: csp_nonce.clone(),
5969            version: env!("CARGO_PKG_VERSION"),
5970        }
5971        .render()
5972        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
5973        return Html(html).into_response();
5974    };
5975
5976    // Ensure older scan is always the baseline.
5977    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
5978        (entry_a, entry_b)
5979    } else {
5980        (entry_b, entry_a)
5981    };
5982
5983    // If query params were in the wrong order, redirect to canonical URL so the
5984    // browser always shows the same URL for the same two scans regardless of how
5985    // the user arrived here (Full diff button vs. Compare Scans selection).
5986    if baseline_entry.run_id != run_id_a {
5987        let canonical = format!(
5988            "/compare?a={}&b={}",
5989            baseline_entry.run_id, current_entry.run_id
5990        );
5991        return axum::response::Redirect::to(&canonical).into_response();
5992    }
5993
5994    let (Some(base_json), Some(curr_json)) = (
5995        baseline_entry.json_path.as_ref(),
5996        current_entry.json_path.as_ref(),
5997    ) else {
5998        let html = ErrorTemplate {
5999            message: "Full comparison requires JSON scan data, which was not saved for one or \
6000                      both of these runs. JSON is now always saved for new scans — re-run the \
6001                      affected projects to enable comparisons."
6002                .to_string(),
6003            last_report_url: Some("/compare-scans".to_string()),
6004            last_report_label: Some("Compare Scans".to_string()),
6005            run_id: None,
6006            error_code: None,
6007            csp_nonce: csp_nonce.clone(),
6008            version: env!("CARGO_PKG_VERSION"),
6009        }
6010        .render()
6011        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
6012        return Html(html).into_response();
6013    };
6014
6015    let compare_url = format!(
6016        "/compare?a={}&b={}",
6017        baseline_entry.run_id, current_entry.run_id
6018    );
6019
6020    let baseline_run = match load_scan_for_compare(
6021        base_json,
6022        "baseline",
6023        &baseline_entry.run_id,
6024        state.server_mode,
6025        &compare_url,
6026        &csp_nonce,
6027    ) {
6028        Ok(r) => r,
6029        Err(resp) => return resp,
6030    };
6031    let current_run = match load_scan_for_compare(
6032        curr_json,
6033        "current",
6034        &current_entry.run_id,
6035        state.server_mode,
6036        &compare_url,
6037        &csp_nonce,
6038    ) {
6039        Ok(r) => r,
6040        Err(resp) => return resp,
6041    };
6042
6043    let active_submodule = query.sub.clone();
6044    let super_scope_active = query.scope.as_deref() == Some("super");
6045
6046    let submodule_options = baseline_run
6047        .submodule_summaries
6048        .iter()
6049        .chain(current_run.submodule_summaries.iter())
6050        .map(|s| s.name.clone())
6051        .collect::<std::collections::BTreeSet<_>>()
6052        .into_iter()
6053        .collect::<Vec<_>>();
6054    let has_any_submodule_data = !submodule_options.is_empty();
6055
6056    // Narrow per_file_records when a scope is active, then recompute totals.
6057    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
6058        let mut b = baseline_run;
6059        let mut c = current_run;
6060        b.per_file_records
6061            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6062        c.per_file_records
6063            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6064        recompute_summary_from_records(&mut b);
6065        recompute_summary_from_records(&mut c);
6066        (b, c)
6067    } else if super_scope_active {
6068        let mut b = baseline_run;
6069        let mut c = current_run;
6070        b.per_file_records.retain(|f| f.submodule.is_none());
6071        c.per_file_records.retain(|f| f.submodule.is_none());
6072        recompute_summary_from_records(&mut b);
6073        recompute_summary_from_records(&mut c);
6074        (b, c)
6075    } else {
6076        (baseline_run, current_run)
6077    };
6078
6079    let comparison = compute_delta(&effective_baseline, &effective_current);
6080
6081    let file_rows: Vec<CompareFileDeltaRow> = comparison
6082        .file_deltas
6083        .iter()
6084        .map(|d| CompareFileDeltaRow {
6085            relative_path: d.relative_path.clone(),
6086            language: d.language.clone().unwrap_or_else(|| "—".into()),
6087            status: match d.status {
6088                FileChangeStatus::Added => "added".into(),
6089                FileChangeStatus::Removed => "removed".into(),
6090                FileChangeStatus::Modified => "modified".into(),
6091                FileChangeStatus::Unchanged => "unchanged".into(),
6092            },
6093            baseline_code: d.baseline_code,
6094            current_code: d.current_code,
6095            code_delta_str: fmt_delta(d.code_delta),
6096            code_delta_class: delta_class(d.code_delta).into(),
6097            comment_delta_str: fmt_delta(d.comment_delta),
6098            comment_delta_class: delta_class(d.comment_delta).into(),
6099            total_delta_str: fmt_delta(d.total_delta),
6100            total_delta_class: delta_class(d.total_delta).into(),
6101        })
6102        .collect();
6103
6104    let project_path = baseline_entry
6105        .input_roots
6106        .first()
6107        .map(|s| sanitize_path_str(s))
6108        .unwrap_or_default();
6109    let lines_added = sum_added_code_lines(&comparison);
6110    let lines_removed = sum_removed_code_lines(&comparison);
6111    let churn = compute_churn_stats(
6112        comparison.summary.baseline_code,
6113        comparison.summary.current_code,
6114        lines_added,
6115        lines_removed,
6116    );
6117    let s = &comparison.summary;
6118    let template = CompareTemplate {
6119        version: env!("CARGO_PKG_VERSION"),
6120        project_label: baseline_entry.project_label.clone(),
6121        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
6122        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
6123        baseline_run_id: baseline_entry.run_id.clone(),
6124        current_run_id: current_entry.run_id.clone(),
6125        baseline_run_id_short: baseline_entry
6126            .run_id
6127            .split('-')
6128            .next_back()
6129            .unwrap_or(&baseline_entry.run_id)
6130            .chars()
6131            .take(7)
6132            .collect(),
6133        current_run_id_short: current_entry
6134            .run_id
6135            .split('-')
6136            .next_back()
6137            .unwrap_or(&current_entry.run_id)
6138            .chars()
6139            .take(7)
6140            .collect(),
6141        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
6142        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
6143        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
6144        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
6145        project_path: project_path.clone(),
6146        baseline_code: s.baseline_code,
6147        current_code: s.current_code,
6148        code_lines_delta_str: fmt_delta(s.code_lines_delta),
6149        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
6150        baseline_files: s.baseline_files,
6151        current_files: s.current_files,
6152        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
6153        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
6154        baseline_comments: s.baseline_comments,
6155        current_comments: s.current_comments,
6156        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
6157        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
6158        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
6159        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
6160        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
6161        code_lines_added: lines_added,
6162        code_lines_removed: lines_removed,
6163        new_scope: churn.new_scope,
6164        churn_rate_str: churn.churn_rate_str,
6165        churn_rate_class: churn.churn_rate_class,
6166        scope_flag: churn.scope_flag,
6167        files_added: comparison.files_added,
6168        files_removed: comparison.files_removed,
6169        files_modified: comparison.files_modified,
6170        files_unchanged: comparison.files_unchanged,
6171        file_rows,
6172        baseline_git_author: baseline_entry.git_author.clone(),
6173        current_git_author: current_entry.git_author.clone(),
6174        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
6175        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
6176        baseline_git_tags: baseline_entry.git_tags.clone(),
6177        current_git_tags: current_entry.git_tags.clone(),
6178        baseline_git_commit_date: baseline_entry
6179            .git_commit_date
6180            .as_deref()
6181            .and_then(fmt_git_date),
6182        current_git_commit_date: current_entry
6183            .git_commit_date
6184            .as_deref()
6185            .and_then(fmt_git_date),
6186        project_name: project_path
6187            .rsplit(['/', '\\'])
6188            .find(|s| !s.is_empty())
6189            .unwrap_or(&project_path)
6190            .to_string(),
6191        submodule_options,
6192        has_any_submodule_data,
6193        active_submodule,
6194        super_scope_active,
6195        csp_nonce,
6196        coverage_delta_card: build_coverage_delta_card(s),
6197    };
6198
6199    Html(
6200        template
6201            .render()
6202            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6203    )
6204    .into_response()
6205}
6206
6207// ── Badge endpoint ────────────────────────────────────────────────────────────
6208// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
6209// pages, Jira descriptions, etc.
6210//
6211// GET /badge/<metric>?label=<override>&color=<hex>
6212// Metrics: code-lines  files  comment-lines  blank-lines
6213
6214fn format_number(n: u64) -> String {
6215    let s = n.to_string();
6216    let mut out = String::with_capacity(s.len() + s.len() / 3);
6217    let len = s.len();
6218    for (i, c) in s.chars().enumerate() {
6219        if i > 0 && (len - i).is_multiple_of(3) {
6220            out.push(',');
6221        }
6222        out.push(c);
6223    }
6224    out
6225}
6226
6227const fn badge_char_width(c: char) -> f64 {
6228    match c {
6229        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
6230        'm' | 'w' => 9.0,
6231        ' ' => 4.0,
6232        _ => 6.5,
6233    }
6234}
6235
6236#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6237fn badge_text_px(text: &str) -> u32 {
6238    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
6239}
6240
6241fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
6242    let lw = badge_text_px(label) + 20;
6243    let rw = badge_text_px(value) + 20;
6244    let total = lw + rw;
6245    let lx = lw / 2;
6246    let rx = lw + rw / 2;
6247    let le = escape_html(label);
6248    let ve = escape_html(value);
6249    let ce = escape_html(color);
6250    format!(
6251        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
6252  <rect width="{total}" height="20" fill="#555"/>
6253  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
6254  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
6255    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
6256    <text x="{lx}" y="13">{le}</text>
6257    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
6258    <text x="{rx}" y="13">{ve}</text>
6259  </g>
6260</svg>"##
6261    )
6262}
6263
6264#[derive(Deserialize)]
6265struct BadgeQuery {
6266    label: Option<String>,
6267    color: Option<String>,
6268}
6269
6270async fn badge_handler(
6271    State(state): State<AppState>,
6272    AxumPath(metric): AxumPath<String>,
6273    Query(query): Query<BadgeQuery>,
6274) -> Response {
6275    let entry = {
6276        let reg = state.registry.lock().await;
6277        reg.entries.first().cloned()
6278    };
6279
6280    let Some(entry) = entry else {
6281        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
6282        return (
6283            [
6284                (header::CONTENT_TYPE, "image/svg+xml"),
6285                (header::CACHE_CONTROL, "no-cache, max-age=0"),
6286            ],
6287            svg,
6288        )
6289            .into_response();
6290    };
6291
6292    let (default_label, value, default_color) = match metric.as_str() {
6293        "code-lines" => (
6294            "code lines",
6295            format_number(entry.summary.code_lines),
6296            "#4a78ee",
6297        ),
6298        "files" => (
6299            "files analyzed",
6300            format_number(entry.summary.files_analyzed),
6301            "#4a9862",
6302        ),
6303        "comment-lines" => (
6304            "comment lines",
6305            format_number(entry.summary.comment_lines),
6306            "#b35428",
6307        ),
6308        "blank-lines" => (
6309            "blank lines",
6310            format_number(entry.summary.blank_lines),
6311            "#7a5db0",
6312        ),
6313        _ => return StatusCode::NOT_FOUND.into_response(),
6314    };
6315
6316    let label = query.label.as_deref().unwrap_or(default_label);
6317    let color = query.color.as_deref().unwrap_or(default_color);
6318    let svg = render_badge_svg(label, &value, color);
6319
6320    (
6321        [
6322            (header::CONTENT_TYPE, "image/svg+xml"),
6323            (header::CACHE_CONTROL, "no-cache, max-age=0"),
6324        ],
6325        svg,
6326    )
6327        .into_response()
6328}
6329
6330// ── Metrics API ───────────────────────────────────────────────────────────────
6331// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
6332// Confluence automation, Jira webhooks, etc.
6333//
6334// GET /api/metrics/latest
6335// GET /api/metrics/<run_id>
6336
6337#[derive(Serialize)]
6338struct ApiCoverageBlock {
6339    lines_found: u64,
6340    lines_hit: u64,
6341    line_pct: f64,
6342    functions_found: u64,
6343    functions_hit: u64,
6344    function_pct: f64,
6345    branches_found: u64,
6346    branches_hit: u64,
6347    branch_pct: f64,
6348}
6349
6350#[derive(Serialize)]
6351struct ApiMetricsResponse {
6352    run_id: String,
6353    timestamp: String,
6354    project: String,
6355    summary: ApiSummaryPayload,
6356    languages: Vec<ApiLanguageRow>,
6357    #[serde(skip_serializing_if = "Option::is_none")]
6358    coverage: Option<ApiCoverageBlock>,
6359}
6360
6361#[derive(Serialize)]
6362struct ApiSummaryPayload {
6363    files_analyzed: u64,
6364    files_skipped: u64,
6365    code_lines: u64,
6366    comment_lines: u64,
6367    blank_lines: u64,
6368    total_physical_lines: u64,
6369    functions: u64,
6370    classes: u64,
6371    variables: u64,
6372    imports: u64,
6373}
6374
6375#[derive(Serialize)]
6376struct ApiLanguageRow {
6377    name: String,
6378    files: u64,
6379    code_lines: u64,
6380    comment_lines: u64,
6381    blank_lines: u64,
6382    functions: u64,
6383    classes: u64,
6384    variables: u64,
6385    imports: u64,
6386}
6387
6388async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
6389    let entry = {
6390        let reg = state.registry.lock().await;
6391        reg.entries.first().cloned()
6392    };
6393    entry.map_or_else(
6394        || error::not_found("no scans recorded yet"),
6395        |e| build_metrics_response(&e),
6396    )
6397}
6398
6399async fn api_metrics_run_handler(
6400    State(state): State<AppState>,
6401    AxumPath(run_id): AxumPath<String>,
6402) -> Response {
6403    let entry = {
6404        let reg = state.registry.lock().await;
6405        reg.find_by_run_id(&run_id).cloned()
6406    };
6407    entry.map_or_else(
6408        || error::not_found("run not found"),
6409        |e| build_metrics_response(&e),
6410    )
6411}
6412
6413fn build_metrics_response(entry: &RegistryEntry) -> Response {
6414    let languages: Vec<ApiLanguageRow> = entry
6415        .json_path
6416        .as_ref()
6417        .and_then(|p| read_json(p).ok())
6418        .map(|run| {
6419            run.totals_by_language
6420                .iter()
6421                .map(|l| ApiLanguageRow {
6422                    name: l.language.display_name().to_string(),
6423                    files: l.files,
6424                    code_lines: l.code_lines,
6425                    comment_lines: l.comment_lines,
6426                    blank_lines: l.blank_lines,
6427                    functions: l.functions,
6428                    classes: l.classes,
6429                    variables: l.variables,
6430                    imports: l.imports,
6431                })
6432                .collect()
6433        })
6434        .unwrap_or_default();
6435
6436    let s = &entry.summary;
6437    let coverage = if s.coverage_lines_found > 0 {
6438        let pct = |hit: u64, found: u64| -> f64 {
6439            if found == 0 {
6440                0.0
6441            } else {
6442                #[allow(clippy::cast_precision_loss)]
6443                let v = (hit as f64 / found as f64) * 100.0;
6444                (v * 10.0).round() / 10.0
6445            }
6446        };
6447        Some(ApiCoverageBlock {
6448            lines_found: s.coverage_lines_found,
6449            lines_hit: s.coverage_lines_hit,
6450            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
6451            functions_found: s.coverage_functions_found,
6452            functions_hit: s.coverage_functions_hit,
6453            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
6454            branches_found: s.coverage_branches_found,
6455            branches_hit: s.coverage_branches_hit,
6456            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
6457        })
6458    } else {
6459        None
6460    };
6461    Json(ApiMetricsResponse {
6462        run_id: entry.run_id.clone(),
6463        timestamp: entry.timestamp_utc.to_rfc3339(),
6464        project: entry.project_label.clone(),
6465        summary: ApiSummaryPayload {
6466            files_analyzed: s.files_analyzed,
6467            files_skipped: s.files_skipped,
6468            code_lines: s.code_lines,
6469            comment_lines: s.comment_lines,
6470            blank_lines: s.blank_lines,
6471            total_physical_lines: s.total_physical_lines,
6472            functions: s.functions,
6473            classes: s.classes,
6474            variables: s.variables,
6475            imports: s.imports,
6476        },
6477        languages,
6478        coverage,
6479    })
6480    .into_response()
6481}
6482
6483// ── Project history API ───────────────────────────────────────────────────────
6484// Protected. Called by the wizard JS when the project path changes, so the UI
6485// can show a "scanned N times before" badge without a full page reload.
6486//
6487// GET /api/project-history?path=<project_root>
6488
6489#[derive(Deserialize)]
6490struct ProjectHistoryQuery {
6491    path: Option<String>,
6492}
6493
6494#[derive(Serialize)]
6495struct ProjectHistoryResponse {
6496    scan_count: usize,
6497    last_scan_id: Option<String>,
6498    last_scan_timestamp: Option<String>,
6499    last_scan_code_lines: Option<u64>,
6500    last_git_branch: Option<String>,
6501    last_git_commit: Option<String>,
6502}
6503
6504/// Return true if `entry` matches either an exact root path or an upload-staging
6505/// path with the same project name (needed because each upload gets a fresh UUID dir).
6506fn entry_matches_project(
6507    entry: &RegistryEntry,
6508    root_str: &str,
6509    upload_root: &str,
6510    upload_name_suffix: Option<&str>,
6511) -> bool {
6512    if entry.input_roots.iter().any(|r| r == root_str) {
6513        return true;
6514    }
6515    if let Some(suffix) = upload_name_suffix {
6516        return entry
6517            .input_roots
6518            .iter()
6519            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
6520    }
6521    false
6522}
6523
6524async fn project_history_handler(
6525    State(state): State<AppState>,
6526    Query(query): Query<ProjectHistoryQuery>,
6527) -> Response {
6528    let path = query.path.unwrap_or_default();
6529    let resolved = resolve_input_path(&path);
6530    let root_str = resolved.to_string_lossy().replace('\\', "/");
6531
6532    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
6533    // The UUID is freshly generated for every upload, so an exact root_str match never finds
6534    // previous scans of the same project. Fall back to matching by project name within the
6535    // uploads staging directory so Scan History populates correctly across uploads.
6536    let upload_root = std::env::temp_dir()
6537        .join("oxide-sloc-uploads")
6538        .to_string_lossy()
6539        .replace('\\', "/");
6540    let upload_name_suffix: Option<String> =
6541        if state.server_mode && root_str.starts_with(&upload_root) {
6542            resolved
6543                .file_name()
6544                .and_then(|n| n.to_str())
6545                .map(|name| format!("/{name}"))
6546        } else {
6547            None
6548        };
6549    let suffix_ref = upload_name_suffix.as_deref();
6550
6551    let entries: Vec<_> = {
6552        let reg = state.registry.lock().await;
6553        reg.entries
6554            .iter()
6555            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
6556            .cloned()
6557            .collect()
6558    };
6559    let scan_count = entries.len();
6560    let last = entries.first();
6561    let last_scan_id = last.map(|e| e.run_id.clone());
6562    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
6563    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
6564    let last_git_branch = last.and_then(|e| e.git_branch.clone());
6565    let last_git_commit = last.and_then(|e| e.git_commit.clone());
6566
6567    Json(ProjectHistoryResponse {
6568        scan_count,
6569        last_scan_id,
6570        last_scan_timestamp,
6571        last_scan_code_lines,
6572        last_git_branch,
6573        last_git_commit,
6574    })
6575    .into_response()
6576}
6577
6578// ── Metrics history API ───────────────────────────────────────────────────────
6579// Protected. Returns a JSON array of lightweight scan snapshots for plotting
6580// trend charts.
6581//
6582// GET /api/metrics/history?root=<path>&limit=<n>
6583
6584#[derive(Deserialize)]
6585struct MetricsHistoryQuery {
6586    root: Option<String>,
6587    limit: Option<usize>,
6588    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
6589    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
6590    submodule: Option<String>,
6591}
6592
6593#[derive(Serialize)]
6594struct MetricsSubmoduleLink {
6595    name: String,
6596    url: String,
6597}
6598
6599#[derive(Serialize)]
6600struct MetricsHistoryEntry {
6601    run_id: String,
6602    run_id_short: String,
6603    timestamp: String,
6604    commit: Option<String>,
6605    branch: Option<String>,
6606    tags: Vec<String>,
6607    nearest_tag: Option<String>,
6608    code_lines: u64,
6609    comment_lines: u64,
6610    blank_lines: u64,
6611    physical_lines: u64,
6612    files_analyzed: u64,
6613    files_skipped: u64,
6614    test_count: u64,
6615    project_label: String,
6616    html_url: Option<String>,
6617    has_pdf: bool,
6618    submodule_links: Vec<MetricsSubmoduleLink>,
6619    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
6620    #[serde(skip_serializing_if = "Option::is_none")]
6621    coverage_line_pct: Option<f64>,
6622}
6623
6624fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
6625    let mut links: Vec<MetricsSubmoduleLink> = vec![];
6626    let sub_dir = e
6627        .html_path
6628        .as_ref()
6629        .and_then(|p| p.parent())
6630        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6631    let Some(dir) = sub_dir else { return links };
6632    let Ok(rd) = std::fs::read_dir(dir) else {
6633        return links;
6634    };
6635    for entry_res in rd.flatten() {
6636        let fname = entry_res.file_name();
6637        let fname_str = fname.to_string_lossy();
6638        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6639            let stem = &fname_str[..fname_str.len() - 5];
6640            let display = stem[4..].replace('-', " ");
6641            links.push(MetricsSubmoduleLink {
6642                name: display,
6643                url: format!("/runs/{stem}/{}", e.run_id),
6644            });
6645        }
6646    }
6647    links.sort_by(|a, b| a.name.cmp(&b.name));
6648    links
6649}
6650
6651fn apply_submodule_filter(
6652    base: MetricsHistoryEntry,
6653    filter: &str,
6654    e: &sloc_core::history::RegistryEntry,
6655) -> Option<MetricsHistoryEntry> {
6656    let json_path = e.json_path.as_ref()?;
6657    let json_str = std::fs::read_to_string(json_path).ok()?;
6658    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
6659    let sub = run
6660        .submodule_summaries
6661        .iter()
6662        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
6663    let safe = sanitize_project_label(&sub.name);
6664    let artifact_key = format!("sub_{safe}");
6665    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
6666        || base.html_url.clone(),
6667        |run_dir| {
6668            let sub_path = run_dir.join(format!("{artifact_key}.html"));
6669            if sub_path.exists() {
6670                Some(format!("/runs/{artifact_key}/{}", e.run_id))
6671            } else {
6672                base.html_url.clone()
6673            }
6674        },
6675    );
6676    Some(MetricsHistoryEntry {
6677        code_lines: sub.code_lines,
6678        comment_lines: sub.comment_lines,
6679        blank_lines: sub.blank_lines,
6680        physical_lines: sub.total_physical_lines,
6681        files_analyzed: sub.files_analyzed,
6682        html_url: sub_html_url,
6683        has_pdf: false,
6684        submodule_links: vec![],
6685        ..base
6686    })
6687}
6688
6689#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
6690async fn api_metrics_history_handler(
6691    State(state): State<AppState>,
6692    Query(query): Query<MetricsHistoryQuery>,
6693) -> Response {
6694    let limit = query.limit.unwrap_or(50).min(500);
6695    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
6696
6697    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
6698        let reg = state.registry.lock().await;
6699        reg.entries
6700            .iter()
6701            .filter(|e| {
6702                query.root.as_ref().is_none_or(|root| {
6703                    let resolved = resolve_input_path(root);
6704                    let root_str = resolved.to_string_lossy().replace('\\', "/");
6705                    e.input_roots.iter().any(|r| r == &root_str)
6706                })
6707            })
6708            .take(limit)
6709            .cloned()
6710            .collect()
6711    };
6712
6713    let entries: Vec<MetricsHistoryEntry> = candidate_entries
6714        .into_iter()
6715        .filter_map(|e| {
6716            let tags = e
6717                .git_tags
6718                .as_deref()
6719                .map(|s| {
6720                    s.split(',')
6721                        .map(|t| t.trim().to_string())
6722                        .filter(|t| !t.is_empty())
6723                        .collect()
6724                })
6725                .unwrap_or_default();
6726            let html_url = e
6727                .html_path
6728                .as_ref()
6729                .filter(|p| p.exists())
6730                .map(|_| format!("/runs/html/{}", e.run_id));
6731            let nearest_tag = e.git_nearest_tag.clone();
6732            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
6733            let run_id_short: String = e
6734                .run_id
6735                .split('-')
6736                .next_back()
6737                .unwrap_or(&e.run_id)
6738                .chars()
6739                .take(7)
6740                .collect();
6741            let submodule_links = build_entry_submodule_links(&e);
6742            #[allow(clippy::cast_precision_loss)]
6743            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
6744                let pct = (e.summary.coverage_lines_hit as f64
6745                    / e.summary.coverage_lines_found as f64)
6746                    * 100.0;
6747                Some((pct * 10.0).round() / 10.0)
6748            } else {
6749                None
6750            };
6751            let base = MetricsHistoryEntry {
6752                run_id: e.run_id.clone(),
6753                run_id_short,
6754                timestamp: e.timestamp_utc.to_rfc3339(),
6755                commit: e.git_commit.clone(),
6756                branch: e.git_branch.clone(),
6757                tags,
6758                nearest_tag,
6759                code_lines: e.summary.code_lines,
6760                comment_lines: e.summary.comment_lines,
6761                blank_lines: e.summary.blank_lines,
6762                physical_lines: e.summary.total_physical_lines,
6763                files_analyzed: e.summary.files_analyzed,
6764                files_skipped: e.summary.files_skipped,
6765                test_count: e.summary.test_count,
6766                project_label: e.project_label.clone(),
6767                html_url,
6768                has_pdf,
6769                submodule_links,
6770                coverage_line_pct,
6771            };
6772            if let Some(ref filter) = submodule_filter {
6773                apply_submodule_filter(base, filter, &e)
6774            } else {
6775                Some(base)
6776            }
6777        })
6778        .collect();
6779
6780    Json(entries).into_response()
6781}
6782
6783// GET /api/metrics/submodules?root=<path>
6784// Returns the union of distinct submodule names found across all saved scan JSON artifacts
6785// for the given project root (or all roots if omitted).
6786#[derive(Deserialize)]
6787struct MetricsSubmodulesQuery {
6788    root: Option<String>,
6789}
6790
6791#[derive(Serialize)]
6792struct SubmoduleEntry {
6793    name: String,
6794    relative_path: String,
6795}
6796
6797async fn api_metrics_submodules_handler(
6798    State(state): State<AppState>,
6799    Query(query): Query<MetricsSubmodulesQuery>,
6800) -> Response {
6801    let json_paths: Vec<std::path::PathBuf> = {
6802        let reg = state.registry.lock().await;
6803        reg.entries
6804            .iter()
6805            .filter(|e| {
6806                query.root.as_ref().is_none_or(|root| {
6807                    let resolved = resolve_input_path(root);
6808                    let root_str = resolved.to_string_lossy().replace('\\', "/");
6809                    e.input_roots.iter().any(|r| r == &root_str)
6810                })
6811            })
6812            .filter_map(|e| e.json_path.clone())
6813            .collect()
6814    };
6815
6816    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6817    let mut result: Vec<SubmoduleEntry> = Vec::new();
6818
6819    for path in &json_paths {
6820        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
6821            continue;
6822        };
6823        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
6824            continue;
6825        };
6826        for sub in &run.submodule_summaries {
6827            if seen.insert(sub.name.clone()) {
6828                result.push(SubmoduleEntry {
6829                    name: sub.name.clone(),
6830                    relative_path: sub.relative_path.clone(),
6831                });
6832            }
6833        }
6834    }
6835
6836    result.sort_by(|a, b| a.name.cmp(&b.name));
6837    Json(result).into_response()
6838}
6839
6840// ── CI ingest endpoint ────────────────────────────────────────────────────────
6841// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
6842// server stores and displays results without cloning or scanning anything itself.
6843//
6844// POST /api/ingest?label=<optional_display_name>
6845// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
6846// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
6847
6848#[derive(Deserialize)]
6849struct IngestQuery {
6850    label: Option<String>,
6851}
6852
6853#[derive(Serialize)]
6854struct IngestResponse {
6855    run_id: String,
6856    view_url: String,
6857}
6858
6859async fn api_ingest_handler(
6860    State(state): State<AppState>,
6861    Query(q): Query<IngestQuery>,
6862    Json(run): Json<sloc_core::AnalysisRun>,
6863) -> Response {
6864    let label = q.label.unwrap_or_else(|| {
6865        run.input_roots
6866            .first()
6867            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
6868    });
6869
6870    let label_for_task = label.clone();
6871    let result = tokio::task::spawn_blocking(move || {
6872        let html = render_html(&run)?;
6873        let run_id = run.tool.run_id.clone();
6874        let run_id_safe = run_id.len() <= 128
6875            && !run_id.is_empty()
6876            && run_id
6877                .chars()
6878                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
6879        if !run_id_safe {
6880            anyhow::bail!(
6881                "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
6882            );
6883        }
6884        let project_label = sanitize_project_label(&label_for_task);
6885        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
6886        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
6887            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
6888            _ => project_label,
6889        };
6890        let (artifacts, _pending_pdf) = persist_run_artifacts(
6891            &run,
6892            &html,
6893            &output_dir,
6894            true,
6895            true,
6896            false,
6897            &label_for_task,
6898            &file_stem,
6899            RunResultContext::default(),
6900        )?;
6901        Ok::<_, anyhow::Error>((run_id, artifacts, run))
6902    })
6903    .await;
6904
6905    match result {
6906        Ok(Ok((run_id, artifacts, run))) => {
6907            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
6908            (
6909                StatusCode::CREATED,
6910                Json(IngestResponse {
6911                    view_url: format!("/view-reports?run_id={run_id}"),
6912                    run_id,
6913                }),
6914            )
6915                .into_response()
6916        }
6917        Ok(Err(e)) => error::internal(&format!("{e:#}")),
6918        Err(e) => error::internal(&format!("{e}")),
6919    }
6920}
6921
6922// ── Trend report page ─────────────────────────────────────────────────────────
6923// Protected. Interactive time-series chart page that loads scan history via
6924// /api/metrics/history and renders a vanilla-SVG line chart.
6925//
6926// GET /trend-reports
6927
6928#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
6929async fn trend_report_handler(
6930    State(state): State<AppState>,
6931    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6932) -> Response {
6933    auto_scan_watched_dirs(&state).await;
6934
6935    let watched_dirs_list: Vec<String> = {
6936        let wd = state.watched_dirs.lock().await;
6937        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6938    };
6939
6940    // Collect distinct project roots for the root selector dropdown.
6941    let roots: Vec<String> = {
6942        let reg = state.registry.lock().await;
6943        let mut seen = std::collections::BTreeSet::new();
6944        reg.entries
6945            .iter()
6946            .flat_map(|e| e.input_roots.iter().cloned())
6947            .filter(|r| seen.insert(r.clone()))
6948            .collect()
6949    };
6950
6951    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
6952    let nonce = &csp_nonce;
6953    let version = env!("CARGO_PKG_VERSION");
6954
6955    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
6956    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
6957    // of interactive controls — folder watching is managed by the host administrator.
6958    let watched_dirs_html: String = if state.server_mode {
6959        r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
6960    } else {
6961        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
6962            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
6963                .to_string()
6964        } else {
6965            watched_dirs_list
6966                .iter()
6967                .fold(String::new(), |mut s, d| {
6968                    use std::fmt::Write as _;
6969                    let escaped =
6970                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
6971                    write!(
6972                        s,
6973                        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>"#
6974                    ).expect("write to String is infallible");
6975                    s
6976                })
6977        };
6978        format!(
6979            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>"#
6980        )
6981    };
6982
6983    let html = format!(
6984        r##"<!doctype html>
6985<html lang="en">
6986<head>
6987  <meta charset="utf-8" />
6988  <meta name="viewport" content="width=device-width, initial-scale=1" />
6989  <title>OxideSLOC | Trend Reports</title>
6990  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6991  <style nonce="{nonce}">
6992    :root {{
6993      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
6994      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6995      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
6996      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6997      --info-bg:#eef3ff; --info-text:#4467d8;
6998    }}
6999    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7000    *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
7001    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7002    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7003    .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;}}
7004    @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));}}}}
7005    .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);}}
7006    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7007    .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));}}
7008    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7009    .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;}}
7010    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7011    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7012    @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; }} }}
7013    .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;}}
7014    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7015    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7016    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7017    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7018    .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;}}
7019    .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;}}
7020    .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;}}
7021    .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;}}
7022    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7023    .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);}}
7024    .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;}}
7025    .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;}}
7026    .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;}}
7027    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7028    .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;}}
7029    .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);}}
7030    .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;}}
7031    .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;}}
7032    .tz-select:focus{{border-color:var(--oxide);}}
7033    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
7034    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7035    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7036    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7037    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
7038    .trend-title-block{{flex:1;min-width:0;}}
7039    .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;}}
7040    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
7041    .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;}}
7042    .chart-select:focus{{border-color:var(--accent);}}
7043    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7044    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7045    .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;}}
7046    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7047    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7048    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7049    .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);}}
7050    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7051    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7052    .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;}}
7053    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
7054    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
7055    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
7056    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7057    .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;}}
7058    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
7059    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
7060    .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);}}
7061    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
7062    .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;}}
7063    .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;}}
7064    .data-table tr:last-child td{{border-bottom:none;}}
7065    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
7066    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
7067    .table-wrap{{width:100%;overflow-x:auto;}}
7068    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
7069    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
7070    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
7071    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
7072    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
7073    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
7074    .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;}}
7075    .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;}}
7076    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
7077    .pagination-info{{font-size:13px;color:var(--muted);}}
7078    .pagination-btns{{display:flex;gap:6px;}}
7079    .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;}}
7080    .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;}}
7081    #scan-history-table col:nth-child(1){{width:155px;}}
7082    #scan-history-table col:nth-child(2){{width:240px;}}
7083    #scan-history-table col:nth-child(3){{width:82px;}}
7084    #scan-history-table col:nth-child(4){{width:82px;}}
7085    #scan-history-table col:nth-child(5){{width:90px;}}
7086    #scan-history-table col:nth-child(6){{width:90px;}}
7087    #scan-history-table col:nth-child(7){{width:88px;}}
7088    #scan-history-table col:nth-child(8){{width:150px;}}
7089    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
7090    .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;}}
7091    .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;}}
7092    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
7093    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
7094    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7095    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7096    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7097    .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;}}
7098    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7099    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7100    .watched-chip-rm:hover{{color:var(--oxide);}}
7101    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7102    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7103    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7104    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7105    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
7106    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
7107    a.run-link:hover{{text-decoration:underline;}}
7108    .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);}}
7109    .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);}}
7110    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
7111    .metric-num{{font-weight:700;color:var(--text);}}
7112    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
7113    .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;}}
7114    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
7115    .btn.primary:hover{{opacity:.9;}}
7116    .rpt-btn{{min-width:58px;justify-content:center;}}
7117    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
7118    .report-cell{{overflow:visible!important;white-space:normal!important;}}
7119    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
7120    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
7121    .submod-details summary::-webkit-details-marker{{display:none;}}
7122    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
7123    .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;}}
7124    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
7125    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
7126    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
7127    .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;}}
7128    .export-btn:hover{{background:var(--line);}}
7129    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
7130    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7131    .site-footer a{{color:var(--muted);}}
7132    .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;}}
7133    .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;}}
7134    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
7135  </style>
7136</head>
7137<body>
7138  <div class="background-watermarks" aria-hidden="true">
7139    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7140    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7141    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7142    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7143    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7144    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7145  </div>
7146  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7147  <div class="top-nav">
7148    <div class="top-nav-inner">
7149      <a class="brand" href="/">
7150        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7151        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
7152      </a>
7153      <div class="nav-right">
7154        <a class="nav-pill" href="/">Home</a>
7155        <div class="nav-dropdown">
7156          <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>
7157          <div class="nav-dropdown-menu">
7158            <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>
7159          </div>
7160        </div>
7161        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7162        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
7163        <div class="nav-dropdown">
7164          <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>
7165          <div class="nav-dropdown-menu">
7166            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
7167          </div>
7168        </div>
7169        <div class="server-status-wrap" id="server-status-wrap">
7170          <div class="nav-pill server-online-pill" id="server-status-pill">
7171            <span class="status-dot" id="status-dot"></span>
7172            <span id="server-status-label">Server</span>
7173            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
7174          </div>
7175          <div class="server-status-tip">
7176            OxideSLOC is running — accessible on your network.
7177            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
7178          </div>
7179        </div>
7180        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7181          <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>
7182        </button>
7183        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7184          <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>
7185          <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>
7186        </button>
7187      </div>
7188    </div>
7189  </div>
7190
7191  <div class="page">
7192    {watched_dirs_html}
7193    <div class="summary-strip" id="trend-stats"></div>
7194    <div class="panel">
7195      <div class="trend-header">
7196        <div class="trend-title-block">
7197          <h1>Trend Reports</h1>
7198          <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>
7199          <span class="chart-hint-inline">
7200            <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>
7201            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
7202          </span>
7203        </div>
7204        <div class="chart-actions">
7205          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
7206            <svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
7207            Clean up old runs
7208          </button>
7209          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
7210            <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>
7211            Export Excel
7212          </button>
7213          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
7214            <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>
7215            Export PNG
7216          </button>
7217        </div>
7218      </div>
7219
7220      <div class="controls-centered">
7221        <label>Project Root:
7222          <select class="chart-select" id="root-sel">
7223            <option value="">All projects</option>
7224          </select>
7225        </label>
7226        <label>Y Metric:
7227          <select class="chart-select" id="y-sel">
7228            <option value="code_lines">Code Lines</option>
7229            <option value="comment_lines">Comment Lines</option>
7230            <option value="blank_lines">Blank Lines</option>
7231            <option value="physical_lines">Physical Lines</option>
7232            <option value="files_analyzed">Files Analyzed</option>
7233          </select>
7234        </label>
7235        <label>X Axis:
7236          <select class="chart-select" id="x-sel">
7237            <option value="time">By Time</option>
7238            <option value="commit">By Commit</option>
7239            <option value="release">By Release</option>
7240            <option value="tag">Tagged Commits</option>
7241          </select>
7242        </label>
7243        <label id="submodule-label" style="display:none;">Submodule:
7244          <select class="chart-select" id="sub-sel">
7245            <option value="">All (project total)</option>
7246          </select>
7247        </label>
7248        <label>Chart Size:
7249          <select class="chart-select" id="scale-sel">
7250            <option value="0.75">Compact</option>
7251            <option value="1.2" selected>Normal</option>
7252            <option value="1.38">Large</option>
7253          </select>
7254        </label>
7255      </div>
7256
7257      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
7258      <div id="data-table-wrap" style="overflow-x:auto;"></div>
7259    </div>
7260  </div>
7261
7262  <script nonce="{nonce}">
7263    (function() {{
7264      // Theme persistence
7265      var b = document.body;
7266      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7267      var tgl = document.getElementById('theme-toggle');
7268      if (tgl) tgl.addEventListener('click', function() {{
7269        var d = b.classList.toggle('dark-theme');
7270        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7271      }});
7272
7273      // Watermark randomizer
7274      (function() {{
7275        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7276        if (!wms.length) return;
7277        var placed = [];
7278        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;}}
7279        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];}}
7280        var half=Math.floor(wms.length/2);
7281        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;}});
7282      }})();
7283
7284      // Code particles
7285      (function() {{
7286        var container = document.getElementById('code-particles');
7287        if (!container) return;
7288        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'];
7289        for (var i = 0; i < 38; i++) {{
7290          (function(idx) {{
7291            var el = document.createElement('span');
7292            el.className = 'code-particle';
7293            el.textContent = snippets[idx % snippets.length];
7294            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7295            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7296            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7297            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';
7298            container.appendChild(el);
7299          }})(i);
7300        }}
7301      }})();
7302
7303      // Watched folder picker
7304      (function() {{
7305        var btn = document.getElementById('add-watched-btn');
7306        if (!btn) return;
7307        btn.addEventListener('click', function() {{
7308          fetch('/pick-directory?kind=reports')
7309            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
7310            .then(function(data) {{
7311              if (!data.cancelled && data.selected_path) {{
7312                var form = document.createElement('form');
7313                form.method = 'POST';
7314                form.action = '/watched-dirs/add';
7315                var ri = document.createElement('input');
7316                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7317                var fi = document.createElement('input');
7318                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7319                form.appendChild(ri); form.appendChild(fi);
7320                document.body.appendChild(form);
7321                form.submit();
7322              }}
7323            }})
7324            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7325        }});
7326      }})();
7327
7328      // Settings / color-scheme modal
7329      (function() {{
7330        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'}}];
7331        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);}});}}
7332        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7333        var btn=document.getElementById('settings-btn');if(!btn)return;
7334        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7335        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>';
7336        document.body.appendChild(m);
7337        var g=document.getElementById('scheme-grid');
7338        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);}});
7339        var cl=document.getElementById('settings-close');
7340        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);
7341        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');}});
7342        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7343        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7344      }})();
7345    }})();
7346
7347    var ROOTS = {roots_json};
7348    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7349    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
7350    var allData = [];
7351
7352    // Populate root selector
7353    var rootSel = document.getElementById('root-sel');
7354    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
7355
7356    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();}}
7357    function fmtFull(n){{return Number(n).toLocaleString();}}
7358    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
7359
7360    // Tooltip
7361    var tt = document.createElement('div');
7362    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);';
7363    document.body.appendChild(tt);
7364    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
7365    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';}}
7366    function hideTT(){{tt.style.display='none';}}
7367
7368    function statExact(compact, full){{
7369      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
7370    }}
7371    function statVal(n){{
7372      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
7373    }}
7374
7375    function updateStats(data){{
7376      var statsEl=document.getElementById('trend-stats');
7377      if(!statsEl)return;
7378      if(!data||!data.length){{statsEl.innerHTML='';return;}}
7379      var yKey=document.getElementById('y-sel').value;
7380      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7381      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7382      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
7383      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
7384      var absDelta=Math.abs(delta);
7385      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
7386      var deltaExact=statExact(deltaCompact,deltaFull);
7387      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
7388      statsEl.innerHTML=
7389        '<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>'+
7390        '<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>'+
7391        '<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>'+
7392        '<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>';
7393    }}
7394
7395    var subSel = document.getElementById('sub-sel');
7396    var subLabel = document.getElementById('submodule-label');
7397
7398    function populateSubmodules(root){{
7399      if(!subSel||!subLabel)return;
7400      while(subSel.options.length>1)subSel.remove(1);
7401      subSel.value='';
7402      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
7403      fetch(url)
7404        .then(function(r){{return r.json();}})
7405        .then(function(subs){{
7406          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
7407          subs.forEach(function(s){{
7408            var o=document.createElement('option');
7409            o.value=s.name;
7410            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
7411            subSel.appendChild(o);
7412          }});
7413          subLabel.style.display='';
7414        }})
7415        .catch(function(){{subLabel.style.display='none';}});
7416    }}
7417
7418    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
7419
7420    function loadAndRender(){{
7421      var root = rootSel.value;
7422      var sub = subSel ? subSel.value : '';
7423      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
7424      document.getElementById('data-table-wrap').innerHTML='';
7425      var url = '/api/metrics/history?limit=100'
7426        + (root ? '&root='+encodeURIComponent(root) : '')
7427        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
7428      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
7429        allData = data;
7430        render(data);
7431        updateStats(data);
7432      }}).catch(function(){{
7433        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>';
7434      }});
7435    }}
7436
7437    function render(data){{
7438      var yKey = document.getElementById('y-sel').value;
7439      var xMode = document.getElementById('x-sel').value;
7440
7441      // Filter for tag/release mode
7442      var pts = data;
7443      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
7444
7445      // Sort oldest-first for the line chart
7446      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7447
7448      var wrap = document.getElementById('chart-wrap');
7449      if(!pts.length){{
7450        var emptyMsg = (xMode === 'tag')
7451          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
7452          : 'No scan data found for the selected filters.';
7453        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
7454        renderTable([]);
7455        return;
7456      }}
7457
7458      var scaleEl=document.getElementById('scale-sel');
7459      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
7460      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;
7461      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
7462
7463      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7464
7465      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">';
7466      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>';
7467
7468      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
7469
7470      // Grid + Y axis ticks
7471      for(var ti=0;ti<=5;ti++){{
7472        var gy=PT+CH-Math.round(ti/5*CH);
7473        var gv=Math.round(ti/5*maxY);
7474        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
7475        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
7476      }}
7477
7478      // X axis labels (every N-th point to avoid crowding)
7479      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
7480      pts.forEach(function(d,i){{
7481        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7482        if(i%labelEvery===0||i===pts.length-1){{
7483          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)));
7484          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>';
7485        }}
7486      }});
7487
7488      // Axis label
7489      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
7490      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>';
7491      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>';
7492
7493      // Area fill + line path
7494      var pathD='';
7495      pts.forEach(function(d,i){{
7496        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7497        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7498        pathD+=(i===0?'M':'L')+x+','+y;
7499      }});
7500      if(pts.length>1){{
7501        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
7502        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
7503      }}
7504      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
7505
7506      // Data points (clickable) + permanent value labels
7507      var showLabels = pts.length <= 40;
7508      var labelEveryN = pts.length > 20 ? 2 : 1;
7509      pts.forEach(function(d,i){{
7510        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7511        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7512        var hasTags=d.tags&&d.tags.length>0;
7513        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
7514        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
7515        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+'"/>';
7516        if(showLabels && i%labelEveryN===0){{
7517          var lx=x, ly=y-r-5;
7518          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>';
7519        }}
7520      }});
7521
7522      svg+='</svg>';
7523      wrap.innerHTML=svg;
7524
7525      // Attach point tooltips
7526      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
7527        c.addEventListener('mouseover',function(e){{
7528          var d=pts[parseInt(this.dataset.idx)];
7529          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(''):'';
7530          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>':'';
7531          showTT(e,
7532            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
7533            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
7534            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
7535            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
7536          );
7537          this.setAttribute('r','8');
7538        }});
7539        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
7540        c.addEventListener('mousemove',moveTT);
7541        c.addEventListener('click',function(){{
7542          var d=pts[parseInt(this.dataset.idx)];
7543          if(d.html_url) window.open(d.html_url,'_blank');
7544        }});
7545      }});
7546
7547      renderTable(pts, yKey);
7548    }}
7549
7550    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
7551    var shProjFilter='', shBranchFilter='';
7552
7553    function fmtPST(isoStr){{
7554      if(!isoStr)return'';
7555      var d=new Date(isoStr);
7556      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
7557      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);}}
7558      function p(n){{return n<10?'0'+n:String(n);}}
7559      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++;}}}}
7560      var yr=d.getUTCFullYear();
7561      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
7562      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
7563      var isDST=d>=dstStart&&d<dstEnd;
7564      var off=isDST?-7*3600*1000:-8*3600*1000;
7565      var lbl=isDST?'PDT':'PST';
7566      var loc=new Date(d.getTime()+off);
7567      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
7568    }}
7569
7570    function getShRows(){{
7571      var proj=shProjFilter.toLowerCase().trim();
7572      var branch=shBranchFilter;
7573      return shData.filter(function(d){{
7574        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
7575        if(branch&&(d.branch||'')!==branch)return false;
7576        return true;
7577      }});
7578    }}
7579
7580    function renderShPage(){{
7581      var filtered=getShRows();
7582      if(shSortCol){{
7583        filtered.sort(function(a,b){{
7584          var va,vb;
7585          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
7586          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
7587          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
7588          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
7589          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
7590          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
7591        }});
7592      }}
7593      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
7594      shPage=Math.min(shPage,totalPages);
7595      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
7596      var visible=filtered.slice(start,end);
7597      var tbody=document.getElementById('sh-tbody');
7598      if(!tbody)return;
7599      tbody.innerHTML=visible.map(function(d){{
7600        var tsHtml=esc(fmtPST(d.timestamp));
7601        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>';
7602        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>';
7603        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
7604        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
7605        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
7606        var reportCell='';
7607        if(d.html_url){{
7608          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
7609          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>';}}
7610          reportCell+='</div>';
7611        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
7612        if(d.submodule_links&&d.submodule_links.length){{
7613          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
7614          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
7615          reportCell+='</div></details>';
7616        }}
7617        return '<tr>'
7618          +'<td>'+tsHtml+'</td>'
7619          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
7620          +'<td>'+runIdHtml+'</td>'
7621          +'<td>'+commitHtml+'</td>'
7622          +'<td>'+branchHtml+'</td>'
7623          +'<td>'+tags+'</td>'
7624          +'<td class="num">'+metricHtml+'</td>'
7625          +'<td class="report-cell">'+reportCell+'</td>'
7626          +'</tr>';
7627      }}).join('');
7628      var pgRange=document.getElementById('sh-pg-range');
7629      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
7630      var pgInfo=document.getElementById('sh-pg-info');
7631      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
7632      var pgBtns=document.getElementById('sh-pg-btns');
7633      if(pgBtns){{
7634        pgBtns.innerHTML='';
7635        function mkPgBtn(lbl,pg,active,disabled){{
7636          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
7637          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
7638          return b;
7639        }}
7640        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
7641        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
7642        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
7643        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
7644      }}
7645    }}
7646
7647    function wireTableBehavior(){{
7648      var pf=document.getElementById('sh-proj-filter');
7649      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
7650      var bf=document.getElementById('sh-branch-filter');
7651      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
7652      var rb=document.getElementById('sh-reset-btn');
7653      if(rb)rb.addEventListener('click',function(){{
7654        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
7655        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
7656        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
7657        document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
7658        renderShPage();
7659      }});
7660      var pps=document.getElementById('sh-per-page');
7661      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
7662      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
7663      ths.forEach(function(th){{
7664        th.addEventListener('click',function(e){{
7665          if(e.target.classList.contains('col-resize-handle'))return;
7666          var col=th.dataset.col;
7667          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
7668          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
7669          th.classList.add('sort-'+shSortOrder);
7670          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
7671          shPage=1;renderShPage();
7672        }});
7673      }});
7674      var table=document.getElementById('scan-history-table');
7675      if(!table)return;
7676      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
7677      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
7678      allThs.forEach(function(th,i){{
7679        var handle=th.querySelector('.col-resize-handle');
7680        if(!handle||!cols[i])return;
7681        var startX,startW;
7682        handle.addEventListener('mousedown',function(e){{
7683          e.stopPropagation();e.preventDefault();
7684          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
7685          handle.classList.add('dragging');
7686          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
7687          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
7688          document.addEventListener('mousemove',onMove);
7689          document.addEventListener('mouseup',onUp);
7690        }});
7691      }});
7692    }}
7693
7694    function renderTable(pts, yKey){{
7695      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
7696      var wrap=document.getElementById('data-table-wrap');
7697      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
7698      var yLabel=Y_LABELS[yKey]||yKey||'';
7699      shData=pts.slice().reverse();
7700      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
7701      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
7702      var branches={{}};
7703      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
7704      var branchOpts='<option value="">All branches</option>';
7705      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
7706      wrap.innerHTML=
7707        '<div class="chart-section-header">SCAN HISTORY</div>'+
7708        '<div class="filter-row">'+
7709          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
7710          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
7711          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
7712        '</div>'+
7713        '<div class="table-wrap">'+
7714        '<table id="scan-history-table" class="data-table">'+
7715        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
7716        '<thead><tr id="sh-thead">'+
7717        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7718        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7719        '<th>Run ID<div class="col-resize-handle"></div></th>'+
7720        '<th>Commit<div class="col-resize-handle"></div></th>'+
7721        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7722        '<th>Tags<div class="col-resize-handle"></div></th>'+
7723        '<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>'+
7724        '<th>Report<div class="col-resize-handle"></div></th>'+
7725        '</tr></thead>'+
7726        '<tbody id="sh-tbody"></tbody>'+
7727        '</table>'+
7728        '</div>'+
7729        '<div class="pagination">'+
7730          '<span class="pagination-info" id="sh-pg-info"></span>'+
7731          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
7732          '<div style="display:flex;align-items:center;gap:8px;">'+
7733            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
7734            '<select class="filter-select" id="sh-per-page">'+
7735              '<option value="10">10 per page</option>'+
7736              '<option value="25" selected>25 per page</option>'+
7737              '<option value="50">50 per page</option>'+
7738              '<option value="100">100 per page</option>'+
7739            '</select>'+
7740            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
7741          '</div>'+
7742        '</div>';
7743      wireTableBehavior();
7744      renderShPage();
7745    }}
7746
7747    function exportXLSX(){{
7748      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
7749      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
7750      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
7751      var s1R=sorted.map(function(d){{
7752        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||''];
7753      }});
7754      var pm={{}};
7755      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
7756      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'];
7757      var s2R=Object.keys(pm).map(function(p){{
7758        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7759        var lat=sc[sc.length-1],fst=sc[0];
7760        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
7761        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);
7762        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];
7763      }});
7764      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
7765      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
7766      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
7767      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
7768    }}
7769
7770    function buildXLSX(sheets,chartRows,chartRows2){{
7771      function s2b(s){{return new TextEncoder().encode(s);}}
7772      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
7773      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;}}
7774      function crc32(d){{
7775        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;}}}}
7776        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
7777      }}
7778      function buildSheet(hdr,rows,drawRid,withCtrl){{
7779        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
7780        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
7781        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
7782        x+='<row r="1">';
7783        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
7784        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>';}}
7785        x+='</row>';
7786        rows.forEach(function(row,ri){{
7787          var rn=ri+2;
7788          x+='<row r="'+rn+'">';
7789          row.forEach(function(cell,ci){{
7790            var addr=col2l(ci+1)+rn;
7791            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
7792            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
7793          }});
7794          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>';}}
7795          x+='</row>';
7796        }});
7797        x+='</sheetData>';
7798        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>';}}
7799        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
7800        return x+'</worksheet>';
7801      }}
7802      function buildChartXML(rows){{
7803        var sn="'Scan History'";
7804        var nr=rows.length,er=nr+1;
7805        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'}}];
7806        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7807        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">';
7808        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7809        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7810        sd.forEach(function(s,i){{
7811          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7812          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>';
7813          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7814          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>';
7815          var dlp=(i===2)?'b':'t';
7816          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>';
7817          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7818          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7819          x+='</c:strCache></c:strRef></c:cat>';
7820          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+'"/>';
7821          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7822          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7823        }});
7824        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
7825        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>';
7826        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>';
7827        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7828        return x;
7829      }}
7830      function buildChartXML2(rows){{
7831        var sn="'By Project'";
7832        var nr=rows.length,er=nr+1;
7833        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'}}];
7834        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7835        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">';
7836        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7837        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7838        sd.forEach(function(s,i){{
7839          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7840          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>';
7841          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7842          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>';
7843          var dlp=(i===2)?'b':'t';
7844          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>';
7845          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7846          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7847          x+='</c:strCache></c:strRef></c:cat>';
7848          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+'"/>';
7849          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7850          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7851        }});
7852        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
7853        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>';
7854        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>';
7855        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7856        return x;
7857      }}
7858      function buildChartXML3(rows){{
7859        var sn="'Scan History'";
7860        var nr=rows.length,er=nr+1;
7861        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7862        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">';
7863        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
7864        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7865        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
7866        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>';
7867        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
7868        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>';
7869        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>';
7870        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7871        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7872        x+='</c:strCache></c:strRef></c:cat>';
7873        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+'"/>';
7874        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
7875        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7876        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
7877        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>';
7878        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>';
7879        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>';
7880        return x;
7881      }}
7882      var hasChart=!!(chartRows&&chartRows.length);
7883      var nr=hasChart?chartRows.length:0;
7884      var hasChart2=!!(chartRows2&&chartRows2.length);
7885      var nr2=hasChart2?chartRows2.length:0;
7886      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>';
7887      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"/>';
7888      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
7889      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"/>';}}
7890      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"/>';}}
7891      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
7892      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>';
7893      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
7894      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"/>';}});
7895      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
7896      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>';
7897      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
7898      wbx+='</sheets></workbook>';
7899      var files=[
7900        {{name:'[Content_Types].xml',data:s2b(ct)}},
7901        {{name:'_rels/.rels',data:s2b(dotrels)}},
7902        {{name:'xl/workbook.xml',data:s2b(wbx)}},
7903        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
7904        {{name:'xl/styles.xml',data:s2b(styl)}}
7905      ];
7906      // Chart embedded directly in Scan History (sheet1); By Project is plain
7907      sheets.forEach(function(s,i){{
7908        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)))}});
7909      }});
7910      if(hasChart){{
7911        var fromRow=nr+4,toRow=nr+24;
7912        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>')}});
7913        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7914        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">';
7915        drx+='<xdr:twoCellAnchor editAs="twoCell">';
7916        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>';
7917        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>';
7918        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7919        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7920        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7921        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7922        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
7923        var focRow=toRow+2,focRowEnd=toRow+22;
7924        drx+='<xdr:twoCellAnchor editAs="twoCell">';
7925        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>';
7926        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>';
7927        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7928        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7929        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7930        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
7931        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
7932        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
7933        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>')}});
7934        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
7935        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
7936      }}
7937      if(hasChart2){{
7938        var fromRow2=nr2+4,toRow2=nr2+24;
7939        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>')}});
7940        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7941        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">';
7942        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
7943        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>';
7944        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>';
7945        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7946        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7947        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7948        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7949        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
7950        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
7951        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>')}});
7952        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
7953      }}
7954      var parts=[],offsets=[],total=0;
7955      files.forEach(function(f){{
7956        offsets.push(total);
7957        var nb=s2b(f.name),crc=crc32(f.data);
7958        var h=new DataView(new ArrayBuffer(30+nb.length));
7959        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
7960        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
7961        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
7962        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
7963        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
7964        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
7965        total+=30+nb.length+f.data.length;
7966      }});
7967      var cdStart=total;
7968      files.forEach(function(f,fi){{
7969        var nb=s2b(f.name),crc=crc32(f.data);
7970        var cd=new DataView(new ArrayBuffer(46+nb.length));
7971        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
7972        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
7973        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
7974        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
7975        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
7976        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
7977        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
7978      }});
7979      var cdSz=total-cdStart;
7980      var eocd=new DataView(new ArrayBuffer(22));
7981      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
7982      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
7983      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
7984      parts.push(new Uint8Array(eocd.buffer));
7985      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
7986      var out=new Uint8Array(sz);var off=0;
7987      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
7988      return out.buffer;
7989    }}
7990
7991    function exportPNG(){{
7992      var svgEl=document.querySelector('#chart-wrap svg');
7993      if(!svgEl){{alert('No chart to export yet.');return;}}
7994      var svgStr=new XMLSerializer().serializeToString(svgEl);
7995      var vb=svgEl.viewBox.baseVal,scale=2;
7996      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
7997      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
7998      var url=URL.createObjectURL(blob);
7999      var img=new Image();
8000      img.onload=function(){{
8001        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
8002        var ctx=canvas.getContext('2d');
8003        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
8004        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
8005        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
8006        URL.revokeObjectURL(url);
8007        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
8008      }};
8009      img.src=url;
8010    }}
8011
8012    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
8013      var el=document.getElementById(id);
8014      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
8015    }});
8016    rootSel.addEventListener('change',function(){{
8017      populateSubmodules(rootSel.value);
8018      loadAndRender();
8019    }});
8020    if(subSel)subSel.addEventListener('change',loadAndRender);
8021
8022    var xlsxBtn=document.getElementById('export-xlsx-btn');
8023    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
8024    var pngBtn=document.getElementById('export-png-btn');
8025    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
8026
8027    // ── Clean-up modal ───────────────────────────────────────────────────────
8028    (function(){{
8029      var triggerBtn=document.getElementById('cleanup-runs-btn');
8030      if(!triggerBtn)return;
8031      var modal=document.createElement('div');
8032      modal.style.cssText='display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,0.55);align-items:center;justify-content:center;';
8033      modal.innerHTML='<div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:460px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">'
8034        +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
8035        +'<p style="font-size:13px;color:var(--text);margin:0 0 14px;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
8036        +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
8037        +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
8038        +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:80px;padding:7px 10px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
8039        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
8040        +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
8041        +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
8042        +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
8043        +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
8044        +'</div></div>';
8045      document.body.appendChild(modal);
8046      triggerBtn.addEventListener('click',function(){{
8047        document.getElementById('cleanup-status').style.display='none';
8048        modal.style.display='flex';
8049      }});
8050      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
8051      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
8052      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
8053        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
8054        var confirmBtn=this;
8055        confirmBtn.disabled=true;
8056        var status=document.getElementById('cleanup-status');
8057        status.style.display='block';
8058        status.style.background='#dbeafe';status.style.color='#1e40af';
8059        status.textContent='Deleting\u2026';
8060        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
8061        .then(function(resp){{
8062          return resp.json().then(function(d){{
8063            if(resp.ok){{
8064              status.style.background='#dcfce7';status.style.color='#166534';
8065              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
8066              setTimeout(function(){{window.location.reload();}},1500);
8067            }}else{{
8068              status.style.background='#fee2e2';status.style.color='#991b1b';
8069              status.textContent='Error: '+(d.error||'Unexpected error');
8070              confirmBtn.disabled=false;
8071            }}
8072          }});
8073        }})
8074        .catch(function(e){{
8075          status.style.background='#fee2e2';status.style.color='#991b1b';
8076          status.textContent='Network error: '+String(e);
8077          confirmBtn.disabled=false;
8078        }});
8079      }});
8080    }})();
8081
8082    populateSubmodules(rootSel.value);
8083    loadAndRender();
8084
8085    (function randomizeWatermarks() {{
8086      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8087      if (!wms.length) return;
8088      var placed = [];
8089      function tooClose(top, left) {{
8090        for (var i = 0; i < placed.length; i++) {{
8091          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
8092          if (dt < 16 && dl < 12) return true;
8093        }}
8094        return false;
8095      }}
8096      function pick(leftBand) {{
8097        for (var attempt = 0; attempt < 50; attempt++) {{
8098          var top = Math.random() * 88 + 2;
8099          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8100          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
8101        }}
8102        var top = Math.random() * 88 + 2;
8103        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8104        placed.push([top, left]); return [top, left];
8105      }}
8106      var half = Math.floor(wms.length / 2);
8107      wms.forEach(function (img, i) {{
8108        var pos = pick(i < half);
8109        var size = Math.floor(Math.random() * 100 + 120);
8110        var rot = (Math.random() * 360).toFixed(1);
8111        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
8112        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;
8113      }});
8114    }})();
8115    (function spawnCodeParticles() {{
8116      var container = document.getElementById('code-particles');
8117      if (!container) return;
8118      var snippets = [
8119        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
8120        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
8121        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
8122        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
8123        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
8124      ];
8125      var count = 38;
8126      for (var i = 0; i < count; i++) {{
8127        (function(idx) {{
8128          var el = document.createElement('span');
8129          el.className = 'code-particle';
8130          el.textContent = snippets[idx % snippets.length];
8131          var left = Math.random() * 94 + 2;
8132          var top = Math.random() * 88 + 6;
8133          var dur = (Math.random() * 10 + 9).toFixed(1);
8134          var delay = (Math.random() * 18).toFixed(1);
8135          var rot = (Math.random() * 26 - 13).toFixed(1);
8136          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8137          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
8138          container.appendChild(el);
8139        }})(i);
8140      }}
8141    }})();
8142  </script>
8143  <footer class="site-footer">
8144    local code analysis - metrics, history and reports
8145    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
8146    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8147    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8148    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8149    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
8150  </footer>
8151  <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
8152</body>
8153</html>"##,
8154    );
8155
8156    Html(html).into_response()
8157}
8158
8159fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
8160    use std::collections::HashMap;
8161    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
8162        return vec![];
8163    }
8164    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
8165    for rec in per_file_records {
8166        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
8167            let e = totals.entry(lang.display_name().to_string()).or_default();
8168            e.0 += u64::from(cov.lines_found);
8169            e.1 += u64::from(cov.lines_hit);
8170        }
8171    }
8172    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
8173    let mut pairs: Vec<(String, f64)> = totals
8174        .into_iter()
8175        .filter(|(_, (found, _))| *found > 0)
8176        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
8177        .collect();
8178    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8179    pairs
8180        .iter()
8181        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
8182        .collect()
8183}
8184
8185fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
8186    let mut high = 0u64;
8187    let mut mid = 0u64;
8188    let mut low = 0u64;
8189    for rec in per_file_records {
8190        if let Some(cov) = &rec.coverage {
8191            if cov.lines_found == 0 {
8192                continue;
8193            }
8194            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
8195            if pct >= 80.0 {
8196                high += 1;
8197            } else if pct >= 50.0 {
8198                mid += 1;
8199            } else {
8200                low += 1;
8201            }
8202        }
8203    }
8204    (high, mid, low)
8205}
8206
8207fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
8208    let mut arr: Vec<serde_json::Value> = per_file_records
8209        .iter()
8210        .filter_map(|rec| {
8211            rec.coverage.as_ref().map(|cov| {
8212                let line_pct = if cov.lines_found > 0 {
8213                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
8214                        / 10.0
8215                } else {
8216                    0.0
8217                };
8218                let fn_pct = if cov.functions_found > 0 {
8219                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
8220                        .round()
8221                        / 10.0
8222                } else {
8223                    -1.0
8224                };
8225                serde_json::json!({
8226                    "rel": rec.relative_path,
8227                    "lang": rec.language.map_or("?", |l| l.display_name()),
8228                    "line_pct": line_pct,
8229                    "fn_pct": fn_pct,
8230                    "lhit": cov.lines_hit,
8231                    "lfound": cov.lines_found,
8232                    "fhit": cov.functions_hit,
8233                    "ffound": cov.functions_found,
8234                })
8235            })
8236        })
8237        .collect();
8238    arr.sort_by(|a, b| {
8239        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
8240        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
8241        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
8242    });
8243    arr
8244}
8245
8246#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
8247fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
8248    let mut langs: Vec<&sloc_core::LanguageSummary> = run
8249        .totals_by_language
8250        .iter()
8251        .filter(|l| l.test_count > 0)
8252        .collect();
8253    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8254    let lang_tests: Vec<serde_json::Value> = langs
8255        .iter()
8256        .map(|l| {
8257            let d = if l.code_lines > 0 {
8258                l.test_count as f64 / l.code_lines as f64 * 1000.0
8259            } else {
8260                0.0
8261            };
8262            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8263                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8264                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8265        })
8266        .collect();
8267    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
8268    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8269    let t = &run.summary_totals;
8270    let total_tests = t.test_count;
8271    let density = if t.code_lines > 0 {
8272        total_tests as f64 / t.code_lines as f64 * 1000.0
8273    } else {
8274        0.0
8275    };
8276    let most_tested = langs.first().map_or_else(
8277        || "\u{2014}".to_string(),
8278        |l| l.language.display_name().to_string(),
8279    );
8280    let test_files: u64 = run
8281        .per_file_records
8282        .iter()
8283        .filter(|f| f.raw_line_categories.test_count > 0)
8284        .count() as u64;
8285    let cov_line = if t.coverage_lines_found > 0 {
8286        format!(
8287            "{:.1}",
8288            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
8289        )
8290    } else {
8291        "0".to_string()
8292    };
8293    let cov_fn = if t.coverage_functions_found > 0 {
8294        format!(
8295            "{:.1}",
8296            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
8297        )
8298    } else {
8299        "0".to_string()
8300    };
8301    let cov_branch = if t.coverage_branches_found > 0 {
8302        format!(
8303            "{:.1}",
8304            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
8305        )
8306    } else {
8307        "0".to_string()
8308    };
8309    let has_cov = !cov_arr.is_empty();
8310    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
8311    serde_json::json!({
8312        "totals": {
8313            "test_count": total_tests,
8314            "assertions": t.test_assertion_count,
8315            "suites": t.test_suite_count,
8316            "test_files": test_files,
8317            "total_files": t.files_analyzed,
8318            "density_str": format!("{density:.1}"),
8319            "most_tested": most_tested,
8320            "langs_with_tests": langs.len(),
8321            "cov_line": cov_line,
8322            "cov_fn": cov_fn,
8323            "cov_branch": cov_branch,
8324        },
8325        "lang_tests": lang_tests,
8326        "cov": cov_arr,
8327        "cov_tiers": {"high": high, "mid": mid, "low": low},
8328        "file_cov": file_cov_arr,
8329        "has_coverage": has_cov,
8330        "submodules": {},
8331    })
8332}
8333
8334#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
8335fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
8336    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
8337        .language_summaries
8338        .iter()
8339        .filter(|l| l.test_count > 0)
8340        .collect();
8341    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8342    let lang_tests: Vec<serde_json::Value> = langs
8343        .iter()
8344        .map(|l| {
8345            let d = if l.code_lines > 0 {
8346                l.test_count as f64 / l.code_lines as f64 * 1000.0
8347            } else {
8348                0.0
8349            };
8350            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8351                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8352                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8353        })
8354        .collect();
8355    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
8356    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
8357    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
8358    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
8359    let density = if sub.code_lines > 0 {
8360        total_tests as f64 / sub.code_lines as f64 * 1000.0
8361    } else {
8362        0.0
8363    };
8364    let most_tested = langs.first().map_or_else(
8365        || "\u{2014}".to_string(),
8366        |l| l.language.display_name().to_string(),
8367    );
8368    serde_json::json!({
8369        "totals": {
8370            "test_count": total_tests,
8371            "assertions": total_assertions,
8372            "suites": total_suites,
8373            "test_files": test_files_approx,
8374            "total_files": sub.files_analyzed,
8375            "density_str": format!("{density:.1}"),
8376            "most_tested": most_tested,
8377            "langs_with_tests": langs.len(),
8378            "cov_line": "0",
8379            "cov_fn": "0",
8380            "cov_branch": "0",
8381        },
8382        "lang_tests": lang_tests,
8383        "cov": [],
8384        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
8385        "has_coverage": false,
8386    })
8387}
8388
8389fn compute_cov_json_str(run: &AnalysisRun) -> String {
8390    use std::collections::HashMap;
8391    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
8392    for rec in &run.per_file_records {
8393        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
8394            let e = totals.entry(lang.display_name().to_string()).or_default();
8395            e.0 += u64::from(cov.lines_found);
8396            e.1 += u64::from(cov.lines_hit);
8397        }
8398    }
8399    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
8400    let mut pairs: Vec<(String, f64)> = totals
8401        .into_iter()
8402        .filter(|(_, (found, _))| *found > 0)
8403        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
8404        .collect();
8405    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8406    let parts: Vec<String> = pairs
8407        .iter()
8408        .map(|(lang, pct)| {
8409            let name = lang.replace('"', "\\\"");
8410            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
8411        })
8412        .collect();
8413    format!("[{}]", parts.join(","))
8414}
8415
8416fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
8417    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8418    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
8419}
8420
8421fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
8422    let mut entry = build_test_scope_entry(run);
8423    if !run.submodule_summaries.is_empty() {
8424        let subs: serde_json::Map<String, serde_json::Value> = run
8425            .submodule_summaries
8426            .iter()
8427            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
8428            .collect();
8429        entry["submodules"] = serde_json::Value::Object(subs);
8430    }
8431    entry
8432}
8433
8434fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
8435    let name = l.language.display_name().replace('"', "\\\"");
8436    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
8437    let density = if l.code_lines > 0 {
8438        l.test_count as f64 / l.code_lines as f64 * 1000.0
8439    } else {
8440        0.0
8441    };
8442    format!(
8443        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
8444        name = name,
8445        t = l.test_count,
8446        a = l.test_assertion_count,
8447        s = l.test_suite_count,
8448        c = l.code_lines,
8449        d = density,
8450        f = l.files,
8451    )
8452}
8453
8454fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
8455    let Some(r) = run else {
8456        return "[]".to_string();
8457    };
8458    let mut langs: Vec<&sloc_core::LanguageSummary> = r
8459        .totals_by_language
8460        .iter()
8461        .filter(|l| l.test_count > 0)
8462        .collect();
8463    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8464    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
8465    format!("[{}]", parts.join(","))
8466}
8467
8468/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
8469async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
8470    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
8471    scope_map.insert(
8472        "__all__".to_string(),
8473        latest_run.map_or_else(
8474            || {
8475                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
8476                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
8477                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
8478                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
8479                    "has_coverage":false,"submodules":{}})
8480            },
8481            build_test_scope_entry,
8482        ),
8483    );
8484    let all_roots: Vec<String> = {
8485        let reg = state.registry.lock().await;
8486        let mut seen = std::collections::BTreeSet::new();
8487        reg.entries
8488            .iter()
8489            .flat_map(|e| e.input_roots.iter().cloned())
8490            .filter(|r| seen.insert(r.clone()))
8491            .collect()
8492    };
8493    for root in &all_roots {
8494        let json_path = {
8495            let reg = state.registry.lock().await;
8496            reg.entries
8497                .iter()
8498                .find(|e| e.input_roots.iter().any(|r| r == root))
8499                .and_then(|e| e.json_path.clone())
8500        };
8501        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
8502            let json_str = tokio::fs::read_to_string(&p).await.ok();
8503            json_str
8504                .as_deref()
8505                .and_then(|s| serde_json::from_str(s).ok())
8506        } else {
8507            None
8508        };
8509        if let Some(ref run) = run_for_root {
8510            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
8511        }
8512    }
8513    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
8514}
8515
8516// GET /test-metrics
8517#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
8518#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
8519async fn test_metrics_handler(
8520    State(state): State<AppState>,
8521    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8522) -> Response {
8523    auto_scan_watched_dirs(&state).await;
8524    let watched_dirs_list: Vec<String> = {
8525        let wd = state.watched_dirs.lock().await;
8526        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8527    };
8528    let latest_run: Option<AnalysisRun> = {
8529        let json_path = {
8530            let reg = state.registry.lock().await;
8531            reg.entries.first().and_then(|e| e.json_path.clone())
8532        };
8533        if let Some(p) = json_path {
8534            let json_str = tokio::fs::read_to_string(&p).await.ok();
8535            json_str
8536                .as_deref()
8537                .and_then(|s| serde_json::from_str(s).ok())
8538        } else {
8539            None
8540        }
8541    };
8542
8543    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
8544    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
8545
8546    // Build coverage chart JSON (per-language avg line coverage %).
8547    let cov_json: String = latest_run
8548        .as_ref()
8549        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8550        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
8551
8552    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
8553    let _cov_tier_json: String = latest_run
8554        .as_ref()
8555        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8556        .map_or_else(
8557            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
8558            compute_cov_tier_json_str,
8559        );
8560
8561    let total_tests: u64 = latest_run
8562        .as_ref()
8563        .map_or(0, |r| r.summary_totals.test_count);
8564    let total_assertions: u64 = latest_run
8565        .as_ref()
8566        .map_or(0, |r| r.summary_totals.test_assertion_count);
8567    let total_suites: u64 = latest_run
8568        .as_ref()
8569        .map_or(0, |r| r.summary_totals.test_suite_count);
8570    let total_code: u64 = latest_run
8571        .as_ref()
8572        .map_or(0, |r| r.summary_totals.code_lines);
8573    let workspace_density: f64 = if total_code > 0 {
8574        total_tests as f64 / total_code as f64 * 1000.0
8575    } else {
8576        0.0
8577    };
8578    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
8579        r.totals_by_language
8580            .iter()
8581            .filter(|l| l.test_count > 0)
8582            .count()
8583    });
8584    let most_tested: String = latest_run
8585        .as_ref()
8586        .and_then(|r| {
8587            r.totals_by_language
8588                .iter()
8589                .filter(|l| l.test_count > 0)
8590                .max_by_key(|l| l.test_count)
8591        })
8592        .map_or_else(
8593            || "\u{2014}".to_string(),
8594            |l| l.language.display_name().to_string(),
8595        );
8596    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
8597        r.per_file_records
8598            .iter()
8599            .filter(|f| f.raw_line_categories.test_count > 0)
8600            .count() as u64
8601    });
8602    let total_files_analyzed: u64 = latest_run
8603        .as_ref()
8604        .map_or(0, |r| r.summary_totals.files_analyzed);
8605    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
8606
8607    // Aggregated coverage percentages from summary_totals
8608    let cov_line_pct_str: String = latest_run
8609        .as_ref()
8610        .filter(|r| r.summary_totals.coverage_lines_found > 0)
8611        .map_or_else(
8612            || "0".to_string(),
8613            |r| {
8614                format!(
8615                    "{:.1}",
8616                    r.summary_totals.coverage_lines_hit as f64
8617                        / r.summary_totals.coverage_lines_found as f64
8618                        * 100.0
8619                )
8620            },
8621        );
8622    let cov_fn_pct_str: String = latest_run
8623        .as_ref()
8624        .filter(|r| r.summary_totals.coverage_functions_found > 0)
8625        .map_or_else(
8626            || "0".to_string(),
8627            |r| {
8628                format!(
8629                    "{:.1}",
8630                    r.summary_totals.coverage_functions_hit as f64
8631                        / r.summary_totals.coverage_functions_found as f64
8632                        * 100.0
8633                )
8634            },
8635        );
8636    let cov_branch_pct_str: String = latest_run
8637        .as_ref()
8638        .filter(|r| r.summary_totals.coverage_branches_found > 0)
8639        .map_or_else(
8640            || "0".to_string(),
8641            |r| {
8642                format!(
8643                    "{:.1}",
8644                    r.summary_totals.coverage_branches_hit as f64
8645                        / r.summary_totals.coverage_branches_found as f64
8646                        * 100.0
8647                )
8648            },
8649        );
8650
8651    let cov_no_data_notice = if has_coverage {
8652        String::new()
8653    } else {
8654        String::from(
8655            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
8656<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>
8657<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
8658  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
8659  <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>
8660  <span style="color:var(--muted);font-size:12px;">&middot;</span>
8661  <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>
8662  <span style="color:var(--muted);font-size:12px;">&middot;</span>
8663  <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>
8664</div>
8665<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
8666</div>"#,
8667        )
8668    };
8669
8670    let workspace_density_str = format!("{workspace_density:.1}");
8671    let nonce = &csp_nonce;
8672    let version = env!("CARGO_PKG_VERSION");
8673
8674    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
8675    // of interactive controls — folder watching is managed by the host administrator.
8676    let watched_dirs_html: String = if state.server_mode {
8677        r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
8678    } else {
8679        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
8680            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
8681                .to_string()
8682        } else {
8683            watched_dirs_list
8684                .iter()
8685                .fold(String::new(), |mut s, d| {
8686                    use std::fmt::Write as _;
8687                    let escaped =
8688                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
8689                    write!(
8690                        s,
8691                        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>"#
8692                    ).expect("write to String is infallible");
8693                    s
8694                })
8695        };
8696        format!(
8697            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>"#
8698        )
8699    };
8700
8701    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
8702    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
8703
8704    let html = format!(
8705        r#"<!doctype html>
8706<html lang="en">
8707<head>
8708  <meta charset="utf-8" />
8709  <meta name="viewport" content="width=device-width, initial-scale=1" />
8710  <title>OxideSLOC | Test Metrics</title>
8711  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8712  <style nonce="{nonce}">
8713    :root {{
8714      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
8715      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8716      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
8717      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8718      --info-bg:#eef3ff; --info-text:#4467d8;
8719    }}
8720    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
8721    *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
8722    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8723    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
8724    .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;}}
8725    @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));}}}}
8726    .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);}}
8727    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
8728    .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));}}
8729    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8730    .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;}}
8731    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
8732    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
8733    @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; }} }}
8734    .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;}}
8735    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8736    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8737    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8738    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
8739    .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;}}
8740    .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;}}
8741    .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;}}
8742    .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;}}
8743    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8744    .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);}}
8745    .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;}}
8746    .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;}}
8747    .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;}}
8748    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8749    .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;}}
8750    .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);}}
8751    .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;}}
8752    .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;}}
8753    .tz-select:focus{{border-color:var(--oxide);}}
8754    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8755    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
8756    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
8757    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
8758    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
8759    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
8760    .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;}}
8761    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
8762    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
8763    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
8764    .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;}}
8765    .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;}}
8766    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
8767    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
8768    .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);}}
8769    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
8770    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
8771    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
8772    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
8773    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
8774    .chart-canvas-wrap{{position:relative;height:280px;}}
8775    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8776    .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;}}
8777    .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;}}
8778    .data-table tr:last-child td{{border-bottom:none;}}
8779    .data-table tbody tr:hover td{{background:var(--surface-2);}}
8780    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
8781    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
8782    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
8783    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
8784    .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;}}
8785    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
8786    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
8787    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
8788    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
8789    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
8790    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
8791    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
8792    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
8793    .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;}}
8794    .chart-select:focus{{border-color:var(--accent);}}
8795    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
8796    .trend-canvas-wrap{{position:relative;height:260px;}}
8797    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
8798    .site-footer a{{color:var(--muted);}}
8799    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
8800    .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;}}
8801    .btn:hover{{background:var(--surface-2);}}
8802    .scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin-bottom:14px;position:relative;z-index:1;flex-wrap:wrap;}}
8803    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8804    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
8805    .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;}}
8806    .scope-sel:focus{{border-color:var(--accent);}}
8807    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
8808    .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;}}
8809    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
8810    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8811    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
8812    .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;}}
8813    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
8814    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
8815    .watched-chip-rm:hover{{color:var(--oxide);}}
8816    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
8817    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
8818    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
8819    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
8820    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
8821    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
8822    .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;}}
8823    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
8824    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
8825    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
8826    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
8827    .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;}}
8828    .cov-file-search:focus{{border-color:var(--accent);}}
8829    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
8830    .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;}}
8831    body.dark-theme .cov-file-search{{background:var(--surface);}}
8832  </style>
8833</head>
8834<body>
8835  <div class="background-watermarks" aria-hidden="true">
8836    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8837    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8838    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8839    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8840    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8841    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8842  </div>
8843  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8844  <div class="top-nav">
8845    <div class="top-nav-inner">
8846      <a class="brand" href="/">
8847        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8848        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
8849      </a>
8850      <div class="nav-right">
8851        <a class="nav-pill" href="/">Home</a>
8852        <div class="nav-dropdown">
8853          <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>
8854          <div class="nav-dropdown-menu">
8855            <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>
8856          </div>
8857        </div>
8858        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8859        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
8860        <div class="nav-dropdown">
8861          <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>
8862          <div class="nav-dropdown-menu">
8863            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
8864          </div>
8865        </div>
8866        <div class="server-status-wrap" id="server-status-wrap">
8867          <div class="nav-pill server-online-pill" id="server-status-pill">
8868            <span class="status-dot" id="status-dot"></span>
8869            <span id="server-status-label">Server</span>
8870            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
8871          </div>
8872          <div class="server-status-tip">
8873            OxideSLOC is running — accessible on your network.
8874            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
8875          </div>
8876        </div>
8877        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
8878          <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>
8879        </button>
8880        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8881          <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>
8882          <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>
8883        </button>
8884      </div>
8885    </div>
8886  </div>
8887
8888  <div class="page">
8889    {watched_dirs_html}
8890    <div class="scope-bar">
8891      <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>
8892      <span class="scope-label">Scope</span>
8893      <div class="scope-sel-wrap">
8894        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
8895        <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);">
8896          <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>
8897          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
8898        </div>
8899      </div>
8900    </div>
8901    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8902      <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>
8903      <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>
8904      <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>
8905      <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>
8906    </div>
8907    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8908      <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>
8909      <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>
8910      <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>
8911      <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>
8912    </div>
8913
8914    <div class="panel">
8915      <h1>Test Metrics</h1>
8916      <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>
8917
8918      <div class="chart-row">
8919        <div class="chart-box">
8920          <div class="chart-box-title">Test Definitions by Language</div>
8921          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
8922        </div>
8923        <div class="chart-box">
8924          <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
8925          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
8926        </div>
8927      </div>
8928
8929      <div class="section-header">Language Breakdown</div>
8930      {cov_no_data_notice}
8931      <div style="overflow-x:auto;">
8932        <table class="data-table" id="lang-table">
8933          <thead><tr>
8934            <th>Language</th>
8935            <th class="num">Test Fns</th>
8936            <th class="num">Assertions</th>
8937            <th class="num">Suites</th>
8938            <th class="num">Code Lines</th>
8939            <th class="num">Files</th>
8940            <th class="num">Density / 1K</th>
8941            <th>Relative Density</th>
8942          </tr></thead>
8943          <tbody id="lang-tbody"></tbody>
8944        </table>
8945      </div>
8946    </div>
8947
8948    <div class="panel" id="cov-panel" style="display:none;">
8949      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
8950      <div class="cov-gauge-row" id="cov-gauges">
8951        <div class="cov-gauge-card">
8952          <div class="cov-gauge-label">Line Coverage</div>
8953          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
8954          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
8955          <div class="cov-gauge-sub">Lines hit / instrumented</div>
8956        </div>
8957        <div class="cov-gauge-card">
8958          <div class="cov-gauge-label">Function Coverage</div>
8959          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
8960          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
8961          <div class="cov-gauge-sub">Functions hit / found</div>
8962        </div>
8963        <div class="cov-gauge-card">
8964          <div class="cov-gauge-label">Branch Coverage</div>
8965          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
8966          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
8967          <div class="cov-gauge-sub">Branches hit / found</div>
8968        </div>
8969      </div>
8970      <div class="chart-row">
8971        <div class="chart-box">
8972          <div class="chart-box-title">Line Coverage % by Language</div>
8973          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
8974        </div>
8975        <div class="chart-box">
8976          <div class="chart-box-title">Coverage Tier Distribution</div>
8977          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
8978        </div>
8979      </div>
8980
8981      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
8982      <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>
8983      <div class="cov-file-toolbar">
8984        <div class="cov-filter-tabs" id="cov-filter-tabs">
8985          <button class="cov-tab active" data-tier="all">All</button>
8986          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
8987          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
8988          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
8989          <button class="cov-tab" data-tier="high">High (≥80%)</button>
8990        </div>
8991        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
8992      </div>
8993      <div style="overflow-x:auto;">
8994        <table class="data-table" id="cov-file-table">
8995          <thead><tr>
8996            <th>File</th>
8997            <th>Lang</th>
8998            <th class="num">Line %</th>
8999            <th class="num">Lines Hit / Found</th>
9000            <th class="num">Fn %</th>
9001            <th class="num">Fns Hit / Found</th>
9002          </tr></thead>
9003          <tbody id="cov-file-tbody"></tbody>
9004        </table>
9005      </div>
9006      <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>
9007      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
9008    </div>
9009
9010    <div class="panel">
9011      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
9012      <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
9013      <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
9014      <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
9015    </div>
9016  </div>
9017
9018  <footer class="site-footer">
9019    local code analysis - metrics, history and reports
9020    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
9021    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9022    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9023    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9024    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
9025  </footer>
9026
9027  <script nonce="{nonce}">
9028  (function() {{
9029    // Theme
9030    var b = document.body;
9031    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
9032    var tgl = document.getElementById('theme-toggle');
9033    if (tgl) tgl.addEventListener('click', function() {{
9034      var d = b.classList.toggle('dark-theme');
9035      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
9036    }});
9037
9038    // Watermarks
9039    (function() {{
9040      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9041      if (!wms.length) return;
9042      var placed = [];
9043      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;}}
9044      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];}}
9045      var half=Math.floor(wms.length/2);
9046      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;}});
9047    }})();
9048
9049    // Code particles
9050    (function() {{
9051      var container = document.getElementById('code-particles');
9052      if (!container) return;
9053      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
9054      for (var i = 0; i < 36; i++) {{
9055        (function(idx) {{
9056          var el = document.createElement('span');
9057          el.className = 'code-particle';
9058          el.textContent = snippets[idx % snippets.length];
9059          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
9060          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
9061          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
9062          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';
9063          container.appendChild(el);
9064        }})(i);
9065      }}
9066    }})();
9067
9068    // Settings modal
9069    (function() {{
9070      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'}}];
9071      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);}});}}
9072      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
9073      var btn=document.getElementById('settings-btn');if(!btn)return;
9074      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9075      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>';
9076      document.body.appendChild(m);
9077      var g=document.getElementById('scheme-grid');
9078      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);}});
9079      var cl=document.getElementById('settings-close');
9080      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');}});
9081      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
9082      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
9083    }})();
9084
9085    // Watched folder picker
9086    (function() {{
9087      var btn = document.getElementById('add-watched-btn');
9088      if (!btn) return;
9089      btn.addEventListener('click', function() {{
9090        fetch('/pick-directory?kind=reports')
9091          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
9092          .then(function(data) {{
9093            if (!data.cancelled && data.selected_path) {{
9094              var form = document.createElement('form');
9095              form.method = 'POST';
9096              form.action = '/watched-dirs/add';
9097              var ri = document.createElement('input');
9098              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
9099              var fi = document.createElement('input');
9100              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
9101              form.appendChild(ri); form.appendChild(fi);
9102              document.body.appendChild(form);
9103              form.submit();
9104            }}
9105          }})
9106          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
9107      }});
9108    }})();
9109  }})();
9110  </script>
9111
9112  <script src="/static/chart.js" nonce="{nonce}"></script>
9113  <script nonce="{nonce}">
9114  (function() {{
9115    var SCOPE_DATA = {scope_data_json};
9116    var currentRoot = '__all__';
9117    var currentSub  = '';
9118    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
9119    var ALL_CHARTS = [];
9120
9121    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();}}
9122    function fmtFull(n){{return Number(n).toLocaleString();}}
9123    function isDark(){{return document.body.classList.contains('dark-theme');}}
9124    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
9125    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
9126    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
9127
9128    function getDataset() {{
9129      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
9130      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
9131      return r;
9132    }}
9133    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
9134
9135    function renderTestCharts(D) {{
9136      testsChart = destroyChart(testsChart);
9137      densityChart = destroyChart(densityChart);
9138      if (!D || !D.length) return;
9139      var top15 = D.slice(0, 15);
9140      var canvas1 = document.getElementById('canvas-tests');
9141      if (canvas1) {{
9142        testsChart = new Chart(canvas1, {{
9143          type: 'bar',
9144          data: {{
9145            labels: top15.map(function(d){{ return d.lang; }}),
9146            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
9147          }},
9148          options: {{
9149            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
9150            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
9151            scales: {{
9152              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
9153              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
9154            }}
9155          }}
9156        }});
9157        ALL_CHARTS.push(testsChart);
9158      }}
9159      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
9160      var canvas2 = document.getElementById('canvas-density');
9161      if (canvas2) {{
9162        densityChart = new Chart(canvas2, {{
9163          type: 'bar',
9164          data: {{
9165            labels: topD.map(function(d){{ return d.lang; }}),
9166            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 }}]
9167          }},
9168          options: {{
9169            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
9170            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
9171            scales: {{
9172              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
9173              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
9174            }}
9175          }}
9176        }});
9177        ALL_CHARTS.push(densityChart);
9178      }}
9179    }}
9180
9181    function renderCovCharts(covD, tiers) {{
9182      covChart = destroyChart(covChart);
9183      tierChart = destroyChart(tierChart);
9184      var covCanvas = document.getElementById('canvas-cov');
9185      if (covCanvas && covD && covD.length) {{
9186        covChart = new Chart(covCanvas, {{
9187          type: 'bar',
9188          data: {{
9189            labels: covD.map(function(d){{ return d.lang; }}),
9190            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 }}]
9191          }},
9192          options: {{
9193            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
9194            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
9195            scales: {{
9196              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
9197              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
9198            }}
9199          }}
9200        }});
9201        ALL_CHARTS.push(covChart);
9202      }}
9203      var tierCanvas = document.getElementById('canvas-cov-tiers');
9204      if (tierCanvas && tiers) {{
9205        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
9206        tierChart = new Chart(tierCanvas, {{
9207          type: 'doughnut',
9208          data: {{
9209            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
9210            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
9211          }},
9212          options: {{
9213            responsive: true, maintainAspectRatio: false, cutout: '62%',
9214            plugins: {{
9215              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
9216              tooltip: {{ callbacks: {{ label: function(ctx) {{
9217                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
9218                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
9219              }} }} }}
9220            }}
9221          }}
9222        }});
9223        ALL_CHARTS.push(tierChart);
9224      }}
9225    }}
9226
9227    function buildLangTable(D) {{
9228      var tbody = document.getElementById('lang-tbody');
9229      if (!tbody) return;
9230      if (!D || !D.length) {{
9231        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>';
9232        return;
9233      }}
9234      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
9235      tbody.innerHTML = D.map(function(d) {{
9236        var barW = Math.round(d.density / maxDensity * 120);
9237        return '<tr>' +
9238          '<td><strong>' + d.lang + '</strong></td>' +
9239          '<td class="num">' + fmt(d.tests) + '</td>' +
9240          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
9241          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
9242          '<td class="num">' + fmt(d.code) + '</td>' +
9243          '<td class="num">' + fmt(d.files) + '</td>' +
9244          '<td class="num">' + d.density.toFixed(2) + '</td>' +
9245          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
9246          '</tr>';
9247      }}).join('');
9248    }}
9249
9250    var covFileData = [];
9251    var covFileTier = 'all';
9252    var covFileSearch = '';
9253
9254    function pctBadge(pct) {{
9255      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
9256      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
9257      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
9258    }}
9259
9260    function buildCovFileTable() {{
9261      var tbody = document.getElementById('cov-file-tbody');
9262      var empty = document.getElementById('cov-file-empty');
9263      var count = document.getElementById('cov-file-count');
9264      if (!tbody) return;
9265      var srch = covFileSearch.toLowerCase();
9266      var filtered = covFileData.filter(function(f) {{
9267        if (covFileTier === 'zero' && f.line_pct > 0) return false;
9268        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
9269        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
9270        if (covFileTier === 'high' && f.line_pct < 80) return false;
9271        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
9272        return true;
9273      }});
9274      if (!filtered.length) {{
9275        tbody.innerHTML = '';
9276        if (empty) empty.style.display = '';
9277        if (count) count.textContent = '';
9278        return;
9279      }}
9280      if (empty) empty.style.display = 'none';
9281      var shown = Math.min(filtered.length, 500);
9282      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
9283      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
9284        var fnCol = f.fn_pct < 0
9285          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
9286          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
9287        return '<tr>' +
9288          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
9289          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
9290          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
9291          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
9292          fnCol +
9293          '</tr>';
9294      }}).join('');
9295    }}
9296
9297    (function() {{
9298      var tabs = document.getElementById('cov-filter-tabs');
9299      if (tabs) {{
9300        tabs.addEventListener('click', function(e) {{
9301          var btn = e.target.closest('.cov-tab');
9302          if (!btn) return;
9303          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
9304          btn.classList.add('active');
9305          covFileTier = btn.getAttribute('data-tier');
9306          buildCovFileTable();
9307        }});
9308      }}
9309      var srch = document.getElementById('cov-file-search');
9310      if (srch) {{
9311        srch.addEventListener('input', function() {{
9312          covFileSearch = this.value;
9313          buildCovFileTable();
9314        }});
9315      }}
9316    }})();
9317
9318    function updateCovGauges(t) {{
9319      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
9320      var el;
9321      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
9322      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
9323      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
9324      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
9325      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
9326      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
9327    }}
9328
9329    function applyScope() {{
9330      var d = getDataset();
9331      var t = d.totals;
9332      var el;
9333      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
9334      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
9335      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
9336      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
9337      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
9338      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
9339      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
9340      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
9341      renderTestCharts(d.lang_tests);
9342      buildLangTable(d.lang_tests);
9343      var covPanel = document.getElementById('cov-panel');
9344      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
9345      if (d.has_coverage) {{
9346        renderCovCharts(d.cov, d.cov_tiers);
9347        updateCovGauges(t);
9348        covFileData = d.file_cov || [];
9349        covFileTier = 'all';
9350        covFileSearch = '';
9351        var tabs = document.getElementById('cov-filter-tabs');
9352        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
9353        var srch = document.getElementById('cov-file-search');
9354        if (srch) srch.value = '';
9355        buildCovFileTable();
9356      }}
9357      loadTrend();
9358    }}
9359
9360    // Populate scope-root-sel from SCOPE_DATA keys
9361    (function() {{
9362      var sel = document.getElementById('scope-root-sel');
9363      if (!sel) return;
9364      Object.keys(SCOPE_DATA).forEach(function(k) {{
9365        if (k === '__all__') return;
9366        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
9367      }});
9368    }})();
9369
9370    document.getElementById('scope-root-sel').addEventListener('change', function() {{
9371      currentRoot = this.value;
9372      currentSub = '';
9373      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
9374      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
9375      var subWrap = document.getElementById('scope-sub-wrap');
9376      var subSel  = document.getElementById('scope-sub-sel');
9377      subSel.innerHTML = '<option value="">Entire project</option>';
9378      if (subNames.length) {{
9379        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
9380        subWrap.style.display = 'flex';
9381      }} else {{
9382        subWrap.style.display = 'none';
9383      }}
9384      applyScope();
9385    }});
9386
9387    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
9388      currentSub = this.value;
9389      applyScope();
9390    }});
9391
9392    function buildTrend(data) {{
9393      var trendCanvas = document.getElementById('canvas-trend');
9394      var trendEmpty  = document.getElementById('trend-empty');
9395      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
9396      pts = pts.slice().reverse();
9397      if (!pts.length) {{
9398        if (trendCanvas) trendCanvas.style.display = 'none';
9399        if (trendEmpty) trendEmpty.style.display = '';
9400        return;
9401      }}
9402      if (trendCanvas) trendCanvas.style.display = '';
9403      if (trendEmpty) trendEmpty.style.display = 'none';
9404      trendChart = destroyChart(trendChart);
9405      if (!trendCanvas) return;
9406      trendChart = new Chart(trendCanvas, {{
9407        type: 'line',
9408        data: {{
9409          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
9410          datasets: [{{
9411            label: 'Test Definitions',
9412            data: pts.map(function(d){{ return d.test_count; }}),
9413            borderColor: '#C45C10',
9414            backgroundColor: 'rgba(196,92,16,0.10)',
9415            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
9416            pointRadius: 5, fill: true, tension: 0.3
9417          }}]
9418        }},
9419        options: {{
9420          responsive: true, maintainAspectRatio: false,
9421          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
9422          scales: {{
9423            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
9424            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
9425          }}
9426        }}
9427      }});
9428      ALL_CHARTS.push(trendChart);
9429    }}
9430
9431    function loadTrend() {{
9432      var url = '/api/metrics/history?limit=100';
9433      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
9434      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
9435        buildTrend(data);
9436      }}).catch(function(){{
9437        var trendEmpty = document.getElementById('trend-empty');
9438        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
9439      }});
9440    }}
9441
9442    // Re-render charts on theme toggle
9443    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
9444      setTimeout(function() {{
9445        ALL_CHARTS.forEach(function(c) {{
9446          if (c && c.options && c.options.scales) {{
9447            Object.values(c.options.scales).forEach(function(ax) {{
9448              if (ax.grid) ax.grid.color = clr();
9449              if (ax.ticks) ax.ticks.color = txtClr();
9450            }});
9451            c.update();
9452          }}
9453        }});
9454      }}, 80);
9455    }});
9456
9457    applyScope();
9458  }})();
9459  </script>
9460  <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
9461</body>
9462</html>"#,
9463    );
9464    Html(html).into_response()
9465}
9466
9467// ── Embeddable widget ─────────────────────────────────────────────────────────
9468// Protected. Returns a self-contained HTML page suitable for iframing inside
9469// Jenkins build summaries, Confluence iframe macros, or Jira panels.
9470//
9471// GET /embed/summary?run_id=<uuid>&theme=dark
9472
9473#[derive(Deserialize)]
9474struct EmbedQuery {
9475    run_id: Option<String>,
9476    theme: Option<String>,
9477}
9478
9479async fn embed_handler(
9480    State(state): State<AppState>,
9481    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9482    Query(query): Query<EmbedQuery>,
9483) -> Response {
9484    let entry = {
9485        let reg = state.registry.lock().await;
9486        query.run_id.as_ref().map_or_else(
9487            || reg.entries.first().cloned(),
9488            |id| reg.find_by_run_id(id).cloned(),
9489        )
9490    };
9491
9492    let Some(entry) = entry else {
9493        return Html(
9494            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
9495                .to_string(),
9496        )
9497        .into_response();
9498    };
9499
9500    let dark = query.theme.as_deref() == Some("dark");
9501    let languages: Vec<(String, u64, u64)> = entry
9502        .json_path
9503        .as_ref()
9504        .and_then(|p| read_json(p).ok())
9505        .map(|run| {
9506            run.totals_by_language
9507                .iter()
9508                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
9509                .collect()
9510        })
9511        .unwrap_or_default();
9512
9513    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
9514}
9515
9516fn render_embed_widget(
9517    entry: &RegistryEntry,
9518    languages: &[(String, u64, u64)],
9519    dark: bool,
9520    csp_nonce: &str,
9521) -> String {
9522    let s = &entry.summary;
9523    let total = s.code_lines + s.comment_lines + s.blank_lines;
9524    let code_pct = s
9525        .code_lines
9526        .checked_mul(100)
9527        .and_then(|n| n.checked_div(total))
9528        .unwrap_or(0);
9529
9530    let (bg, fg, surface, muted, border) = if dark {
9531        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
9532    } else {
9533        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
9534    };
9535
9536    let mut lang_rows = String::new();
9537    for (name, files, code) in languages {
9538        write!(
9539            lang_rows,
9540            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
9541            escape_html(name),
9542            format_number(*files),
9543            format_number(*code),
9544        )
9545        .ok();
9546    }
9547
9548    let lang_table = if lang_rows.is_empty() {
9549        String::new()
9550    } else {
9551        format!(
9552            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
9553        )
9554    };
9555
9556    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
9557    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
9558    let project_esc = escape_html(&entry.project_label);
9559    let code_lines = format_number(s.code_lines);
9560    let comment_lines = format_number(s.comment_lines);
9561    let files = format_number(s.files_analyzed);
9562    let code_raw = s.code_lines;
9563    let comment_raw = s.comment_lines;
9564    let blank_raw = s.blank_lines;
9565
9566    format!(
9567        r#"<!doctype html>
9568<html lang="en">
9569<head>
9570  <meta charset="utf-8">
9571  <meta name="viewport" content="width=device-width,initial-scale=1">
9572  <title>OxideSLOC &mdash; {project_esc}</title>
9573  <script src="/static/chart.js"></script>
9574  <style nonce="{csp_nonce}">
9575    *{{box-sizing:border-box;margin:0;padding:0}}
9576    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
9577    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
9578    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
9579    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
9580    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
9581    .card .v{{font-size:18px;font-weight:700}}
9582    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
9583    .row{{display:flex;gap:12px;align-items:flex-start}}
9584    .pie{{width:120px;height:120px;flex-shrink:0}}
9585    .lt{{border-collapse:collapse;width:100%;flex:1}}
9586    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
9587    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
9588    .n{{text-align:right}}
9589    .footer{{margin-top:10px;color:{muted};font-size:10px}}
9590  </style>
9591</head>
9592<body>
9593  <h2>{project_esc}</h2>
9594  <div class="sub">{timestamp} &middot; run {run_short}</div>
9595  <div class="cards">
9596    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
9597    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
9598    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
9599    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
9600  </div>
9601  <div class="row">
9602    <canvas class="pie" id="c"></canvas>
9603    {lang_table}
9604  </div>
9605  <div class="footer">oxide-sloc</div>
9606  <script nonce="{csp_nonce}">
9607    new Chart(document.getElementById('c'),{{
9608      type:'doughnut',
9609      data:{{
9610        labels:['Code','Comments','Blank'],
9611        datasets:[{{
9612          data:[{code_raw},{comment_raw},{blank_raw}],
9613          backgroundColor:['#4a78ee','#b35428','#aaa'],
9614          borderWidth:0
9615        }}]
9616      }},
9617      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
9618    }});
9619  </script>
9620</body>
9621</html>"#
9622    )
9623}
9624
9625#[allow(clippy::too_many_arguments)]
9626fn persist_run_artifacts(
9627    run: &sloc_core::AnalysisRun,
9628    report_html: &str,
9629    run_dir: &Path,
9630    generate_json: bool,
9631    generate_html: bool,
9632    generate_pdf: bool,
9633    report_title: &str,
9634    file_stem: &str,
9635    result_context: RunResultContext,
9636) -> Result<(RunArtifacts, PendingPdf)> {
9637    fs::create_dir_all(run_dir)
9638        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
9639
9640    let mut html_path = None;
9641    let mut pdf_path = None;
9642    let mut json_path = None;
9643    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
9644
9645    if generate_html {
9646        let path = run_dir.join(format!("report_{file_stem}.html"));
9647        fs::write(&path, report_html)
9648            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
9649        html_path = Some(path);
9650    }
9651
9652    if generate_json {
9653        let path = run_dir.join(format!("result_{file_stem}.json"));
9654        let json = serde_json::to_string_pretty(run)
9655            .context("failed to serialize analysis run to JSON")?;
9656        fs::write(&path, json)
9657            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
9658        json_path = Some(path);
9659    }
9660
9661    if generate_pdf {
9662        let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
9663
9664        // Attempt pure-Rust native PDF (zero external dependencies).
9665        // Falls back to the HTML→browser background task on failure.
9666        match write_pdf_from_run(run, &pdf_dest) {
9667            Ok(()) => {
9668                eprintln!(
9669                    "[oxide-sloc][pdf] native PDF written to {}",
9670                    pdf_dest.display()
9671                );
9672                pdf_path = Some(pdf_dest);
9673                // pending_pdf stays None — no background browser task needed.
9674            }
9675            Err(native_err) => {
9676                eprintln!(
9677                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), \
9678                     scheduling HTML→browser fallback"
9679                );
9680                let source_html_path = if let Some(existing) = html_path.as_ref() {
9681                    existing.clone()
9682                } else {
9683                    let temp_html = run_dir.join("_report_rendered.html");
9684                    fs::write(&temp_html, report_html).with_context(|| {
9685                        format!(
9686                            "failed to write temporary HTML report to {}",
9687                            temp_html.display()
9688                        )
9689                    })?;
9690                    temp_html
9691                };
9692                let cleanup_src = !generate_html;
9693                pdf_path = Some(pdf_dest.clone());
9694                pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
9695            }
9696        }
9697    }
9698
9699    // CSV and XLSX are always generated (like JSON) — no extra flag required.
9700    let csv_path = {
9701        let path = run_dir.join(format!("report_{file_stem}.csv"));
9702        if let Err(e) = sloc_report::write_csv(run, &path) {
9703            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
9704            None
9705        } else {
9706            Some(path)
9707        }
9708    };
9709
9710    let xlsx_path = {
9711        let path = run_dir.join(format!("report_{file_stem}.xlsx"));
9712        if let Err(e) = sloc_report::write_xlsx(run, &path) {
9713            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
9714            None
9715        } else {
9716            Some(path)
9717        }
9718    };
9719
9720    let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
9721
9722    Ok((
9723        RunArtifacts {
9724            output_dir: run_dir.to_path_buf(),
9725            html_path,
9726            pdf_path,
9727            json_path,
9728            csv_path,
9729            xlsx_path,
9730            scan_config_path,
9731            report_title: report_title.to_string(),
9732            result_context,
9733        },
9734        pending_pdf,
9735    ))
9736}
9737
9738/// Find a scan-config JSON file in `dir`, checking both the legacy fixed name and
9739/// the current `scan-config_<stem>.json` pattern for backwards compatibility.
9740fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
9741    let exact = dir.join("scan-config.json");
9742    if exact.exists() {
9743        return Some(exact);
9744    }
9745    fs::read_dir(dir).ok().and_then(|entries| {
9746        entries
9747            .filter_map(std::result::Result::ok)
9748            .find(|e| {
9749                let name = e.file_name();
9750                let name = name.to_string_lossy();
9751                name.starts_with("scan-config") && name.ends_with(".json")
9752            })
9753            .map(|e| e.path())
9754    })
9755}
9756
9757// ── Config export / import ────────────────────────────────────────────────────
9758
9759async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
9760    let toml_str = match toml::to_string_pretty(&state.base_config) {
9761        Ok(s) => s,
9762        Err(e) => {
9763            return (
9764                StatusCode::INTERNAL_SERVER_ERROR,
9765                format!("serialization error: {e}"),
9766            )
9767                .into_response();
9768        }
9769    };
9770    (
9771        [
9772            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
9773            (
9774                header::CONTENT_DISPOSITION,
9775                "attachment; filename=\".oxide-sloc.toml\"",
9776            ),
9777        ],
9778        toml_str,
9779    )
9780        .into_response()
9781}
9782
9783#[derive(Serialize)]
9784struct OkResponse {
9785    ok: bool,
9786}
9787
9788#[derive(Serialize)]
9789struct SaveProfileResponse {
9790    ok: bool,
9791    id: String,
9792}
9793
9794#[derive(Serialize)]
9795struct ProfileListResponse {
9796    profiles: Vec<ScanProfile>,
9797}
9798
9799#[derive(Serialize)]
9800struct ImportConfigResponse {
9801    ok: bool,
9802    config: sloc_config::AppConfig,
9803}
9804
9805#[derive(Deserialize)]
9806struct ImportConfigBody {
9807    toml: String,
9808}
9809
9810async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
9811    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
9812        Ok(config) => {
9813            if let Err(e) = config.validate() {
9814                return error::unprocessable_entity(&e.to_string());
9815            }
9816            Json(ImportConfigResponse { ok: true, config }).into_response()
9817        }
9818        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
9819    }
9820}
9821
9822// ── Scan profiles API ─────────────────────────────────────────────────────────
9823
9824async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
9825    let store = state.scan_profiles.lock().await;
9826    Json(ProfileListResponse {
9827        profiles: store.profiles.clone(),
9828    })
9829}
9830
9831#[derive(Deserialize)]
9832struct SaveScanProfileBody {
9833    name: String,
9834    params: serde_json::Value,
9835}
9836
9837async fn api_save_scan_profile(
9838    State(state): State<AppState>,
9839    Json(body): Json<SaveScanProfileBody>,
9840) -> impl IntoResponse {
9841    if body.name.trim().is_empty() {
9842        return error::bad_request("name must not be empty");
9843    }
9844
9845    let id = uuid::Uuid::new_v4().to_string();
9846    let profile = ScanProfile {
9847        id: id.clone(),
9848        name: body.name.trim().to_string(),
9849        created_at: chrono::Utc::now().to_rfc3339(),
9850        params: body.params,
9851    };
9852
9853    let mut store = state.scan_profiles.lock().await;
9854    store.profiles.push(profile);
9855    if let Err(e) = store.save(&state.scan_profiles_path) {
9856        tracing::warn!("failed to persist scan profiles: {e}");
9857    }
9858    drop(store);
9859
9860    (
9861        StatusCode::CREATED,
9862        Json(SaveProfileResponse { ok: true, id }),
9863    )
9864        .into_response()
9865}
9866
9867async fn api_delete_scan_profile(
9868    State(state): State<AppState>,
9869    AxumPath(id): AxumPath<String>,
9870) -> impl IntoResponse {
9871    let mut store = state.scan_profiles.lock().await;
9872    let before = store.profiles.len();
9873    store.profiles.retain(|p| p.id != id);
9874    if store.profiles.len() == before {
9875        drop(store);
9876        return error::not_found("profile not found");
9877    }
9878    if let Err(e) = store.save(&state.scan_profiles_path) {
9879        tracing::warn!("failed to persist scan profiles: {e}");
9880    }
9881    drop(store);
9882    Json(OkResponse { ok: true }).into_response()
9883}
9884
9885fn resolve_output_root(raw: Option<&str>) -> PathBuf {
9886    let value = raw.unwrap_or("out/web").trim();
9887    let path = if value.is_empty() {
9888        PathBuf::from("out/web")
9889    } else {
9890        PathBuf::from(value)
9891    };
9892
9893    if path.is_absolute() {
9894        path
9895    } else {
9896        workspace_root().join(path)
9897    }
9898}
9899
9900/// Derive the directory that holds remote-repo clones from the output root.
9901fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
9902    std::env::var("SLOC_GIT_CLONES_DIR")
9903        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
9904}
9905
9906/// Build a deterministic filesystem path for a cloned remote repository.
9907/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
9908pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
9909    let safe: String = repo_url
9910        .chars()
9911        .map(|c| {
9912            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
9913                c
9914            } else {
9915                '_'
9916            }
9917        })
9918        .take(80)
9919        .collect();
9920    clones_dir.join(safe)
9921}
9922
9923/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
9924/// Runs synchronously — call from `tokio::task::spawn_blocking`.
9925pub(crate) fn scan_path_to_artifacts(
9926    scan_path: &Path,
9927    base_config: &AppConfig,
9928    label: &str,
9929) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
9930    let mut config = base_config.clone();
9931    config.discovery.root_paths = vec![scan_path.to_path_buf()];
9932    label.clone_into(&mut config.reporting.report_title);
9933    let run = analyze(&config, "git", None, None)?;
9934    let html = render_html(&run)?;
9935    let run_id = run.tool.run_id.clone();
9936    let project_label = sanitize_project_label(label);
9937    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9938    let file_stem = {
9939        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
9940        if commit.is_empty() {
9941            project_label
9942        } else {
9943            format!("{project_label}_{commit}")
9944        }
9945    };
9946    let (artifacts, _pending_pdf) = persist_run_artifacts(
9947        &run,
9948        &html,
9949        &output_dir,
9950        true,
9951        true,
9952        false,
9953        label,
9954        &file_stem,
9955        RunResultContext::default(),
9956    )?;
9957    Ok((run_id, artifacts, run))
9958}
9959
9960/// Re-spawn background poll tasks for any polling schedules saved to disk.
9961async fn restart_poll_schedules(state: &AppState) {
9962    let store = state.schedules.lock().await;
9963    let poll_schedules: Vec<_> = store
9964        .schedules
9965        .iter()
9966        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
9967        .cloned()
9968        .collect();
9969    drop(store);
9970    for schedule in poll_schedules {
9971        let interval = schedule.interval_secs.unwrap_or(300);
9972        let st = state.clone();
9973        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
9974    }
9975}
9976
9977fn split_patterns(raw: Option<&str>) -> Vec<String> {
9978    raw.unwrap_or("")
9979        .lines()
9980        .flat_map(|line| line.split(','))
9981        .map(str::trim)
9982        .filter(|part| !part.is_empty())
9983        .map(ToOwned::to_owned)
9984        .collect()
9985}
9986
9987fn build_sub_run(
9988    parent: &AnalysisRun,
9989    sub: &sloc_core::SubmoduleSummary,
9990    parent_path: &str,
9991) -> AnalysisRun {
9992    let sub_files: Vec<_> = parent
9993        .per_file_records
9994        .iter()
9995        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9996        .cloned()
9997        .collect();
9998    let mut config = parent.effective_configuration.clone();
9999    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
10000    AnalysisRun {
10001        tool: parent.tool.clone(),
10002        environment: parent.environment.clone(),
10003        effective_configuration: config,
10004        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
10005        summary_totals: SummaryTotals {
10006            files_considered: sub.files_analyzed,
10007            files_analyzed: sub.files_analyzed,
10008            files_skipped: 0,
10009            total_physical_lines: sub.total_physical_lines,
10010            code_lines: sub.code_lines,
10011            comment_lines: sub.comment_lines,
10012            blank_lines: sub.blank_lines,
10013            mixed_lines_separate: 0,
10014            functions: 0,
10015            classes: 0,
10016            variables: 0,
10017            imports: 0,
10018            test_count: 0,
10019            test_assertion_count: 0,
10020            test_suite_count: 0,
10021            coverage_lines_found: 0,
10022            coverage_lines_hit: 0,
10023            coverage_functions_found: 0,
10024            coverage_functions_hit: 0,
10025            coverage_branches_found: 0,
10026            coverage_branches_hit: 0,
10027        },
10028        totals_by_language: sub.language_summaries.clone(),
10029        per_file_records: sub_files,
10030        skipped_file_records: vec![],
10031        warnings: vec![],
10032        submodule_summaries: vec![],
10033        git_commit_short: parent.git_commit_short.clone(),
10034        git_commit_long: parent.git_commit_long.clone(),
10035        git_branch: parent.git_branch.clone(),
10036        git_commit_author: parent.git_commit_author.clone(),
10037        git_commit_date: parent.git_commit_date.clone(),
10038        git_tags: parent.git_tags.clone(),
10039        git_nearest_tag: parent.git_nearest_tag.clone(),
10040        git_remote_url: parent.git_remote_url.clone(),
10041    }
10042}
10043
10044pub(crate) fn sanitize_project_label(raw: &str) -> String {
10045    let candidate = Path::new(raw)
10046        .file_name()
10047        .and_then(|name| name.to_str())
10048        .unwrap_or("project");
10049
10050    let mut value = String::with_capacity(candidate.len());
10051    for ch in candidate.chars() {
10052        if ch.is_ascii_alphanumeric() {
10053            value.push(ch.to_ascii_lowercase());
10054        } else {
10055            value.push('-');
10056        }
10057    }
10058
10059    let compact = value.trim_matches('-').to_string();
10060    if compact.is_empty() {
10061        "project".to_string()
10062    } else {
10063        compact
10064    }
10065}
10066
10067/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
10068/// comparisons with non-canonicalized stored paths work correctly.
10069fn strip_unc_prefix(path: PathBuf) -> PathBuf {
10070    let s = path.to_string_lossy();
10071    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
10072        return PathBuf::from(format!(r"\\{rest}"));
10073    }
10074    if let Some(rest) = s.strip_prefix(r"\\?\") {
10075        return PathBuf::from(rest);
10076    }
10077    path
10078}
10079
10080/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
10081/// commit page URL for the most common hosting platforms.
10082fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
10083    let base = if let Some(rest) = remote.strip_prefix("git@") {
10084        let (host, path) = rest.split_once(':')?;
10085        format!("https://{}/{}", host, path.trim_end_matches(".git"))
10086    } else if remote.starts_with("https://") || remote.starts_with("http://") {
10087        remote
10088            .trim_end_matches('/')
10089            .trim_end_matches(".git")
10090            .to_owned()
10091    } else {
10092        return None;
10093    };
10094    let base = base.trim_end_matches('/');
10095    // GitLab uses /-/commit/; everything else uses /commit/
10096    if base.contains("gitlab.com") || base.contains("gitlab.") {
10097        Some(format!("{}/-/commit/{}", base, sha))
10098    } else if base.contains("bitbucket.org") {
10099        Some(format!("{}/commits/{}", base, sha))
10100    } else {
10101        Some(format!("{}/commit/{}", base, sha))
10102    }
10103}
10104
10105fn display_path(path: &Path) -> String {
10106    let s = path.to_string_lossy();
10107    // Strip Windows extended-length prefix for display only; the underlying
10108    // PathBuf remains unchanged so file operations are unaffected.
10109    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
10110    // \\?\C:\path           →  C:\path          (local drive)
10111    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
10112        return format!(r"\\{rest}");
10113    }
10114    if let Some(rest) = s.strip_prefix(r"\\?\") {
10115        return rest.to_owned();
10116    }
10117    s.into_owned()
10118}
10119
10120fn sanitize_path_str(s: &str) -> String {
10121    // Forward-slash variants of the Windows extended-length prefix that appear
10122    // when paths stored as plain strings have been processed through some path
10123    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
10124    if let Some(rest) = s.strip_prefix("//?/UNC/") {
10125        return format!("//{rest}");
10126    }
10127    if let Some(rest) = s.strip_prefix("//?/") {
10128        return rest.to_owned();
10129    }
10130    display_path(Path::new(s))
10131}
10132
10133fn workspace_root() -> PathBuf {
10134    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
10135    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
10136        let p = PathBuf::from(root);
10137        if p.is_dir() {
10138            return p;
10139        }
10140    }
10141
10142    // Current working directory — works for `cargo run` from the project root
10143    // and for scripts/run.sh which cds there first.
10144    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
10145}
10146
10147/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
10148fn make_git_label(repo: &str, ref_name: &str) -> String {
10149    if repo.is_empty() || ref_name.is_empty() {
10150        return String::new();
10151    }
10152    let base = repo
10153        .trim_end_matches('/')
10154        .trim_end_matches(".git")
10155        .rsplit('/')
10156        .next()
10157        .unwrap_or("repo");
10158    let ref_safe: String = ref_name
10159        .chars()
10160        .map(|c| {
10161            if c.is_alphanumeric() || c == '-' || c == '.' {
10162                c
10163            } else {
10164                '_'
10165            }
10166        })
10167        .collect();
10168    format!("{base}_at_{ref_safe}_sloc")
10169}
10170
10171/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
10172fn desktop_dir() -> PathBuf {
10173    if let Ok(profile) = std::env::var("USERPROFILE") {
10174        let p = PathBuf::from(profile).join("Desktop");
10175        if p.exists() {
10176            return p;
10177        }
10178    }
10179    if let Ok(home) = std::env::var("HOME") {
10180        let p = PathBuf::from(home).join("Desktop");
10181        if p.exists() {
10182            return p;
10183        }
10184    }
10185    workspace_root().join("out").join("web")
10186}
10187
10188fn resolve_input_path(raw: &str) -> PathBuf {
10189    let trimmed = raw.trim();
10190    if trimmed.is_empty() {
10191        return workspace_root().join("samples").join("basic");
10192    }
10193
10194    let candidate = PathBuf::from(trimmed);
10195    let resolved = if candidate.is_absolute() {
10196        candidate
10197    } else {
10198        let rooted = workspace_root().join(&candidate);
10199        if rooted.exists() {
10200            rooted
10201        } else {
10202            workspace_root().join(candidate)
10203        }
10204    };
10205
10206    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
10207    // strip that prefix so stored paths and the displayed "Project path" are clean.
10208    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
10209    PathBuf::from(display_path(&canonical))
10210}
10211
10212fn dir_size_bytes(path: &Path) -> u64 {
10213    let mut total = 0u64;
10214    if let Ok(rd) = fs::read_dir(path) {
10215        for entry in rd.filter_map(Result::ok) {
10216            let p = entry.path();
10217            if p.is_file() {
10218                if let Ok(meta) = p.metadata() {
10219                    total += meta.len();
10220                }
10221            } else if p.is_dir() {
10222                total += dir_size_bytes(&p);
10223            }
10224        }
10225    }
10226    total
10227}
10228
10229#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
10230fn format_dir_size(bytes: u64) -> String {
10231    if bytes >= 1_073_741_824 {
10232        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
10233    } else if bytes >= 1_048_576 {
10234        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
10235    } else if bytes >= 1_024 {
10236        format!("{:.0} KB", bytes as f64 / 1_024.0)
10237    } else {
10238        format!("{bytes} B")
10239    }
10240}
10241
10242fn render_submodule_chips(
10243    root: &Path,
10244    submodules: &[(String, std::path::PathBuf)],
10245    out: &mut String,
10246) {
10247    use std::fmt::Write as _;
10248    let count = submodules.len();
10249    out.push_str(r#"<div class="submodule-preview-strip">"#);
10250    write!(
10251        out,
10252        r#"<div class="submodule-preview-label"><svg viewBox="0 0 24 24" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/><circle cx="6" cy="6" r="3"/></svg><strong>{count}</strong>&nbsp;git&nbsp;submodule{}&nbsp;detected</div>"#,
10253        if count == 1 { "" } else { "s" }
10254    )
10255    .ok();
10256    out.push_str(r#"<div class="submodule-preview-chips">"#);
10257    for (sub_name, sub_rel_path) in submodules {
10258        let sub_abs = root.join(sub_rel_path);
10259        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
10260        let mut sub_stats = PreviewStats::default();
10261        let mut sub_rows: Vec<PreviewRow> = Vec::new();
10262        let mut sub_langs: Vec<&'static str> = Vec::new();
10263        let mut sub_budget = PreviewBudget {
10264            shown: 0,
10265            max_entries: 2000,
10266            max_depth: 9,
10267        };
10268        let mut sub_next_id = 1usize;
10269        let _ = collect_preview_rows(
10270            &sub_abs,
10271            &sub_abs,
10272            0,
10273            None,
10274            &mut sub_next_id,
10275            &mut sub_budget,
10276            &mut sub_stats,
10277            &mut sub_rows,
10278            &mut sub_langs,
10279            &[],
10280            &[],
10281        );
10282        let stats_json = format!(
10283            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
10284            sub_stats.directories,
10285            sub_stats.files,
10286            sub_stats.supported,
10287            sub_stats.skipped,
10288            sub_stats.unsupported
10289        );
10290        write!(
10291            out,
10292            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>"#,
10293            escape_html(sub_name),
10294            escape_html(&sub_rel_path.to_string_lossy()),
10295            escape_html(&sub_size),
10296            escape_html(&stats_json),
10297            escape_html(sub_name),
10298            escape_html(&sub_size),
10299        )
10300        .ok();
10301    }
10302    out.push_str(
10303        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
10304    );
10305    out.push_str(r"</div>");
10306}
10307
10308fn render_language_pills_row(languages: &[&str], out: &mut String) {
10309    use std::fmt::Write as _;
10310    if languages.is_empty() {
10311        out.push_str(
10312            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
10313        );
10314        return;
10315    }
10316    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
10317    for language in languages {
10318        if let Some(icon) = language_icon_file(language) {
10319            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();
10320        } else if let Some(svg) = language_inline_svg(language) {
10321            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();
10322        } else {
10323            write!(
10324                out,
10325                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
10326                escape_html(&language.to_ascii_lowercase()),
10327                escape_html(language)
10328            )
10329            .ok();
10330        }
10331    }
10332}
10333
10334#[allow(clippy::too_many_lines)]
10335fn build_preview_html(
10336    root: &Path,
10337    include_patterns: &[String],
10338    exclude_patterns: &[String],
10339) -> Result<String> {
10340    if !root.exists() {
10341        return Ok(format!(
10342            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
10343            escape_html(&display_path(root))
10344        ));
10345    }
10346
10347    let _selected = display_path(root);
10348    let mut stats = PreviewStats::default();
10349    let mut rows = Vec::new();
10350    let mut languages = Vec::new();
10351    let mut budget = PreviewBudget {
10352        shown: 0,
10353        max_entries: 600,
10354        max_depth: 9,
10355    };
10356    let mut next_row_id = 1usize;
10357
10358    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
10359        || root.to_string_lossy().into_owned(),
10360        std::string::ToString::to_string,
10361    );
10362    let root_modified = root
10363        .metadata()
10364        .ok()
10365        .and_then(|meta| meta.modified().ok())
10366        .map_or_else(|| "-".to_string(), format_system_time);
10367
10368    rows.push(PreviewRow {
10369        row_id: 0,
10370        parent_row_id: None,
10371        depth: 0,
10372        name: format!("{root_name}/"),
10373        kind: PreviewKind::Dir,
10374        is_dir: true,
10375        language: None,
10376        modified: root_modified,
10377        type_label: "Directory".to_string(),
10378    });
10379    collect_preview_rows(
10380        root,
10381        root,
10382        0,
10383        Some(0),
10384        &mut next_row_id,
10385        &mut budget,
10386        &mut stats,
10387        &mut rows,
10388        &mut languages,
10389        include_patterns,
10390        exclude_patterns,
10391    )?;
10392
10393    let root_size = format_dir_size(dir_size_bytes(root));
10394
10395    let mut out = String::new();
10396    write!(
10397        out,
10398        r#"<div class="explorer-wrap" data-project-size="{}">"#,
10399        escape_html(&root_size)
10400    )
10401    .ok();
10402    out.push_str(r#"<div class="explorer-toolbar compact">"#);
10403    out.push_str(r#"<div class="explorer-title-group">"#);
10404    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
10405    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
10406    out.push_str(r"</div></div>");
10407
10408    out.push_str(r#"<div class="scope-stats">"#);
10409    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();
10410    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();
10411    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();
10412    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();
10413    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();
10414    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>"#);
10415    out.push_str(r"</div>");
10416
10417    let submodules = sloc_core::detect_submodules(root);
10418    if !submodules.is_empty() {
10419        render_submodule_chips(root, &submodules, &mut out);
10420    }
10421
10422    out.push_str(r#"<div class="scope-info-row">"#);
10423    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
10424    render_language_pills_row(&languages, &mut out);
10425    out.push_str(r"</div></div>");
10426    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>"#);
10427    out.push_str(r"</div>");
10428
10429    out.push_str(r#"<div class="file-explorer-shell">"#);
10430    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>"#);
10431    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>"#);
10432    out.push_str(r#"<div class="file-explorer-tree">"#);
10433    for row in rows {
10434        let status_label = row.kind.label();
10435        let lang_attr = row.language.unwrap_or("");
10436        let toggle_html = if row.is_dir {
10437            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
10438                .to_string()
10439        } else {
10440            r#"<span class="tree-bullet">•</span>"#.to_string()
10441        };
10442        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();
10443    }
10444    if budget.shown >= budget.max_entries {
10445        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>"#);
10446    }
10447    out.push_str(r"</div></div></div>");
10448
10449    Ok(out)
10450}
10451
10452#[derive(Default)]
10453struct PreviewStats {
10454    directories: usize,
10455    files: usize,
10456    supported: usize,
10457    skipped: usize,
10458    unsupported: usize,
10459}
10460
10461struct PreviewRow {
10462    row_id: usize,
10463    parent_row_id: Option<usize>,
10464    depth: usize,
10465    name: String,
10466    kind: PreviewKind,
10467    is_dir: bool,
10468    language: Option<&'static str>,
10469    modified: String,
10470    type_label: String,
10471}
10472
10473#[derive(Copy, Clone)]
10474enum PreviewKind {
10475    Dir,
10476    Supported,
10477    Skipped,
10478    Unsupported,
10479}
10480
10481impl PreviewKind {
10482    const fn filter_key(self) -> &'static str {
10483        match self {
10484            Self::Dir => "dir",
10485            Self::Supported => "supported",
10486            Self::Skipped => "skipped",
10487            Self::Unsupported => "unsupported",
10488        }
10489    }
10490
10491    const fn label(self) -> &'static str {
10492        match self {
10493            Self::Dir => "dir",
10494            Self::Supported => "supported",
10495            Self::Skipped => "skipped by policy",
10496            Self::Unsupported => "unsupported",
10497        }
10498    }
10499
10500    const fn badge_class(self) -> &'static str {
10501        match self {
10502            Self::Dir => "badge badge-dir",
10503            Self::Supported => "badge badge-scan",
10504            Self::Skipped => "badge badge-skip",
10505            Self::Unsupported => "badge badge-unsupported",
10506        }
10507    }
10508
10509    const fn node_class(self) -> &'static str {
10510        match self {
10511            Self::Dir => "tree-node-dir",
10512            Self::Supported => "tree-node-supported",
10513            Self::Skipped => "tree-node-skipped",
10514            Self::Unsupported => "tree-node-unsupported",
10515        }
10516    }
10517}
10518
10519struct PreviewBudget {
10520    shown: usize,
10521    max_entries: usize,
10522    max_depth: usize,
10523}
10524
10525/// Handle a single directory entry inside `collect_preview_rows`.
10526/// Returns `true` when the entry was handled (caller should `continue`).
10527#[allow(clippy::too_many_arguments)]
10528fn handle_preview_dir_entry(
10529    root: &Path,
10530    path: &Path,
10531    name: &str,
10532    modified: String,
10533    depth: usize,
10534    parent_row_id: Option<usize>,
10535    row_id: usize,
10536    next_row_id: &mut usize,
10537    budget: &mut PreviewBudget,
10538    stats: &mut PreviewStats,
10539    rows: &mut Vec<PreviewRow>,
10540    languages: &mut Vec<&'static str>,
10541    include_patterns: &[String],
10542    exclude_patterns: &[String],
10543) -> Result<()> {
10544    let relative = preview_relative_path(root, path);
10545    if should_skip_preview_directory(&relative, exclude_patterns) {
10546        return Ok(());
10547    }
10548    stats.directories += 1;
10549    rows.push(PreviewRow {
10550        row_id,
10551        parent_row_id,
10552        depth: depth + 1,
10553        name: format!("{name}/"),
10554        kind: PreviewKind::Dir,
10555        is_dir: true,
10556        language: None,
10557        modified,
10558        type_label: "Directory".to_string(),
10559    });
10560    budget.shown += 1;
10561    if !matches!(name, ".git" | "node_modules" | "target") {
10562        collect_preview_rows(
10563            root,
10564            path,
10565            depth + 1,
10566            Some(row_id),
10567            next_row_id,
10568            budget,
10569            stats,
10570            rows,
10571            languages,
10572            include_patterns,
10573            exclude_patterns,
10574        )?;
10575    }
10576    Ok(())
10577}
10578
10579/// Handle a single file entry inside `collect_preview_rows`.
10580#[allow(clippy::too_many_arguments)]
10581fn handle_preview_file_entry(
10582    root: &Path,
10583    path: &Path,
10584    name: &str,
10585    modified: String,
10586    depth: usize,
10587    parent_row_id: Option<usize>,
10588    row_id: usize,
10589    budget: &mut PreviewBudget,
10590    stats: &mut PreviewStats,
10591    rows: &mut Vec<PreviewRow>,
10592    languages: &mut Vec<&'static str>,
10593    include_patterns: &[String],
10594    exclude_patterns: &[String],
10595) {
10596    let relative = preview_relative_path(root, path);
10597    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
10598        return;
10599    }
10600    stats.files += 1;
10601    let kind = classify_preview_file(name);
10602    match kind {
10603        PreviewKind::Supported => stats.supported += 1,
10604        PreviewKind::Skipped => stats.skipped += 1,
10605        PreviewKind::Unsupported => stats.unsupported += 1,
10606        PreviewKind::Dir => {}
10607    }
10608    let language = detect_language_name(name);
10609    if let Some(lang) = language {
10610        if !languages.contains(&lang) {
10611            languages.push(lang);
10612        }
10613    }
10614    rows.push(PreviewRow {
10615        row_id,
10616        parent_row_id,
10617        depth: depth + 1,
10618        name: name.to_owned(),
10619        kind,
10620        is_dir: false,
10621        language,
10622        modified,
10623        type_label: preview_type_label(name, language, kind),
10624    });
10625    budget.shown += 1;
10626}
10627
10628#[allow(clippy::too_many_arguments)]
10629#[allow(clippy::too_many_lines)]
10630fn collect_preview_rows(
10631    root: &Path,
10632    dir: &Path,
10633    depth: usize,
10634    parent_row_id: Option<usize>,
10635    next_row_id: &mut usize,
10636    budget: &mut PreviewBudget,
10637    stats: &mut PreviewStats,
10638    rows: &mut Vec<PreviewRow>,
10639    languages: &mut Vec<&'static str>,
10640    include_patterns: &[String],
10641    exclude_patterns: &[String],
10642) -> Result<()> {
10643    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
10644        return Ok(());
10645    }
10646
10647    let mut entries = fs::read_dir(dir)
10648        .with_context(|| format!("failed to read directory {}", dir.display()))?
10649        .filter_map(std::result::Result::ok)
10650        .collect::<Vec<_>>();
10651    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
10652
10653    for entry in entries {
10654        if budget.shown >= budget.max_entries {
10655            break;
10656        }
10657
10658        let path = entry.path();
10659        let name = entry.file_name().to_string_lossy().into_owned();
10660        let Ok(metadata) = entry.metadata() else {
10661            continue;
10662        };
10663        let row_id = *next_row_id;
10664        *next_row_id += 1;
10665        let modified = metadata
10666            .modified()
10667            .ok()
10668            .map_or_else(|| "-".to_string(), format_system_time);
10669
10670        if metadata.is_dir() {
10671            handle_preview_dir_entry(
10672                root,
10673                &path,
10674                &name,
10675                modified,
10676                depth,
10677                parent_row_id,
10678                row_id,
10679                next_row_id,
10680                budget,
10681                stats,
10682                rows,
10683                languages,
10684                include_patterns,
10685                exclude_patterns,
10686            )?;
10687            continue;
10688        }
10689
10690        if metadata.is_file() {
10691            handle_preview_file_entry(
10692                root,
10693                &path,
10694                &name,
10695                modified,
10696                depth,
10697                parent_row_id,
10698                row_id,
10699                budget,
10700                stats,
10701                rows,
10702                languages,
10703                include_patterns,
10704                exclude_patterns,
10705            );
10706        }
10707    }
10708
10709    Ok(())
10710}
10711
10712fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
10713    if let Some(language) = language {
10714        return format!("{language} source");
10715    }
10716    let lower = name.to_ascii_lowercase();
10717    let ext = Path::new(&lower)
10718        .extension()
10719        .and_then(|e| e.to_str())
10720        .unwrap_or("");
10721    match kind {
10722        PreviewKind::Skipped => {
10723            if lower.ends_with(".min.js") {
10724                "Minified asset".to_string()
10725            } else if [
10726                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
10727            ]
10728            .contains(&ext)
10729            {
10730                "Binary or archive".to_string()
10731            } else {
10732                "Skipped file".to_string()
10733            }
10734        }
10735        PreviewKind::Unsupported => {
10736            if ext.is_empty() {
10737                "Unsupported file".to_string()
10738            } else {
10739                format!("{} file", ext.to_ascii_uppercase())
10740            }
10741        }
10742        PreviewKind::Supported => "Supported source".to_string(),
10743        PreviewKind::Dir => "Directory".to_string(),
10744    }
10745}
10746
10747fn format_system_time(time: SystemTime) -> String {
10748    #[allow(clippy::cast_possible_wrap)]
10749    let secs = match time.duration_since(UNIX_EPOCH) {
10750        Ok(duration) => duration.as_secs() as i64,
10751        Err(_) => return "-".to_string(),
10752    };
10753    let days = secs.div_euclid(86_400);
10754    let secs_of_day = secs.rem_euclid(86_400);
10755    let (year, month, day) = civil_from_days(days);
10756    let hour = secs_of_day / 3_600;
10757    let minute = (secs_of_day % 3_600) / 60;
10758    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
10759}
10760
10761#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
10762fn civil_from_days(days: i64) -> (i32, u32, u32) {
10763    let z = days + 719_468;
10764    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
10765    let doe = z - era * 146_097;
10766    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
10767    let y = yoe + era * 400;
10768    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
10769    let mp = (5 * doy + 2) / 153;
10770    let d = doy - (153 * mp + 2) / 5 + 1;
10771    let m = mp + if mp < 10 { 3 } else { -9 };
10772    let year = y + i64::from(m <= 2);
10773    (year as i32, m as u32, d as u32)
10774}
10775
10776// The input is already lowercased via `to_ascii_lowercase()` before calling
10777// `ends_with`, so the comparisons are inherently case-insensitive.
10778#[allow(clippy::case_sensitive_file_extension_comparisons)]
10779fn detect_language_name(name: &str) -> Option<&'static str> {
10780    let lower = name.to_ascii_lowercase();
10781    if lower.ends_with(".c") || lower.ends_with(".h") {
10782        Some("C")
10783    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
10784        .iter()
10785        .any(|s| lower.ends_with(s))
10786    {
10787        Some("C++")
10788    } else if lower.ends_with(".cs") {
10789        Some("C#")
10790    } else if lower.ends_with(".py") {
10791        Some("Python")
10792    } else if lower.ends_with(".sh") {
10793        Some("Shell")
10794    } else if [".ps1", ".psm1", ".psd1"]
10795        .iter()
10796        .any(|s| lower.ends_with(s))
10797    {
10798        Some("PowerShell")
10799    } else {
10800        None
10801    }
10802}
10803
10804fn language_icon_file(language: &str) -> Option<&'static str> {
10805    match language {
10806        "C" => Some("c.png"),
10807        "C++" => Some("cpp.png"),
10808        "C#" => Some("c-sharp.png"),
10809        "Python" => Some("python.png"),
10810        "Shell" => Some("shell.png"),
10811        "PowerShell" => Some("powershell.png"),
10812        "JavaScript" => Some("java-script.png"),
10813        "HTML" => Some("html-5.png"),
10814        "Java" => Some("java.png"),
10815        "Visual Basic" => Some("visual-basic.png"),
10816        "Assembly" => Some("asm.png"),
10817        "Go" => Some("go.png"),
10818        "R" => Some("r.png"),
10819        "XML" => Some("xml.png"),
10820        "Groovy" => Some("groovy.png"),
10821        "Dockerfile" => Some("docker.png"),
10822        "Makefile" => Some("makefile.svg"),
10823        "Perl" => Some("perl.svg"),
10824        _ => None,
10825    }
10826}
10827
10828// Inline SVG badges for languages that have no PNG icon in images/icons/.
10829// Using inline SVG keeps the web UI fully self-contained — no extra files
10830// needed on disk, no 404s on air-gapped deployments.
10831// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
10832fn language_inline_svg(language: &str) -> Option<&'static str> {
10833    match language {
10834        "Rust" => Some(
10835            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>"##,
10836        ),
10837        "TypeScript" => Some(
10838            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>"##,
10839        ),
10840        _ => None,
10841    }
10842}
10843
10844// The input is already lowercased via `to_ascii_lowercase()` before the
10845// `ends_with` calls, so these comparisons are inherently case-insensitive.
10846#[allow(clippy::case_sensitive_file_extension_comparisons)]
10847fn classify_preview_file(name: &str) -> PreviewKind {
10848    let lower = name.to_ascii_lowercase();
10849
10850    let scannable = [
10851        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
10852        ".psm1", ".psd1",
10853    ]
10854    .iter()
10855    .any(|suffix| lower.ends_with(suffix));
10856
10857    if scannable {
10858        PreviewKind::Supported
10859    } else if lower.ends_with(".min.js")
10860        || lower.ends_with(".lock")
10861        || lower.ends_with(".png")
10862        || lower.ends_with(".jpg")
10863        || lower.ends_with(".jpeg")
10864        || lower.ends_with(".gif")
10865        || lower.ends_with(".zip")
10866        || lower.ends_with(".pdf")
10867        || lower.ends_with(".pyc")
10868        || lower.ends_with(".xz")
10869        || lower.ends_with(".tar")
10870        || lower.ends_with(".gz")
10871    {
10872        PreviewKind::Skipped
10873    } else {
10874        PreviewKind::Unsupported
10875    }
10876}
10877
10878fn preview_relative_path(root: &Path, path: &Path) -> String {
10879    path.strip_prefix(root)
10880        .ok()
10881        .unwrap_or(path)
10882        .to_string_lossy()
10883        .replace('\\', "/")
10884        .trim_matches('/')
10885        .to_string()
10886}
10887
10888fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
10889    if relative.is_empty() {
10890        return false;
10891    }
10892
10893    exclude_patterns.iter().any(|pattern| {
10894        wildcard_match(pattern, relative)
10895            || wildcard_match(pattern, &format!("{relative}/"))
10896            || wildcard_match(pattern, &format!("{relative}/placeholder"))
10897    })
10898}
10899
10900fn should_include_preview_file(
10901    relative: &str,
10902    include_patterns: &[String],
10903    exclude_patterns: &[String],
10904) -> bool {
10905    if relative.is_empty() {
10906        return true;
10907    }
10908
10909    let included = include_patterns.is_empty()
10910        || include_patterns
10911            .iter()
10912            .any(|pattern| wildcard_match(pattern, relative));
10913    let excluded = exclude_patterns
10914        .iter()
10915        .any(|pattern| wildcard_match(pattern, relative));
10916
10917    included && !excluded
10918}
10919
10920fn wildcard_match(pattern: &str, candidate: &str) -> bool {
10921    let pattern = pattern.trim().replace('\\', "/");
10922    let candidate = candidate.trim().replace('\\', "/");
10923    let p = pattern.as_bytes();
10924    let c = candidate.as_bytes();
10925    let mut pi = 0usize;
10926    let mut ci = 0usize;
10927    let mut star: Option<usize> = None;
10928    let mut star_match = 0usize;
10929
10930    while ci < c.len() {
10931        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
10932            pi += 1;
10933            ci += 1;
10934        } else if pi < p.len() && p[pi] == b'*' {
10935            while pi < p.len() && p[pi] == b'*' {
10936                pi += 1;
10937            }
10938            star = Some(pi);
10939            star_match = ci;
10940        } else if let Some(star_pi) = star {
10941            star_match += 1;
10942            ci = star_match;
10943            pi = star_pi;
10944        } else {
10945            return false;
10946        }
10947    }
10948
10949    while pi < p.len() && p[pi] == b'*' {
10950        pi += 1;
10951    }
10952
10953    pi == p.len()
10954}
10955
10956fn escape_html(value: &str) -> String {
10957    value
10958        .replace('&', "&amp;")
10959        .replace('<', "&lt;")
10960        .replace('>', "&gt;")
10961        .replace('"', "&quot;")
10962        .replace('\'', "&#39;")
10963}
10964
10965#[derive(Clone)]
10966struct SubmoduleRow {
10967    name: String,
10968    relative_path: String,
10969    files_analyzed: u64,
10970    code_lines: u64,
10971    comment_lines: u64,
10972    blank_lines: u64,
10973    total_physical_lines: u64,
10974    html_url: Option<String>,
10975}
10976
10977#[derive(Template)]
10978#[template(
10979    source = r##"
10980<!doctype html>
10981<html lang="en">
10982<head>
10983  <meta charset="utf-8">
10984  <title>OxideSLOC | tmp-sloc</title>
10985  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10986  <style nonce="{{ csp_nonce }}">
10987    :root {
10988      --bg: #efe9e2;
10989      --surface: #fcfaf7;
10990      --surface-2: #f7f0e8;
10991      --surface-3: #efe3d5;
10992      --line: #dfcfbf;
10993      --line-strong: #cfb29c;
10994      --text: #2f241c;
10995      --muted: #6f6257;
10996      --muted-2: #917f71;
10997      --nav: #b85d33;
10998      --nav-2: #7a371b;
10999      --accent: #2563eb;
11000      --accent-2: #1d4ed8;
11001      --oxide: #b85d33;
11002      --oxide-2: #8f4220;
11003      --success-bg: #eaf9ee;
11004      --success-text: #1c8746;
11005      --warn-bg: #fff2d8;
11006      --warn-text: #926000;
11007      --danger-bg: #fdeaea;
11008      --danger-text: #b33b3b;
11009      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
11010      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
11011      --radius: 14px;
11012    }
11013
11014    body.dark-theme {
11015      --bg: #1b1511;
11016      --surface: #261c17;
11017      --surface-2: #2d221d;
11018      --surface-3: #372922;
11019      --line: #524238;
11020      --line-strong: #6c5649;
11021      --text: #f5ece6;
11022      --muted: #c7b7aa;
11023      --muted-2: #aa9485;
11024      --nav: #b85d33;
11025      --nav-2: #7a371b;
11026      --accent: #6f9bff;
11027      --accent-2: #4a78ee;
11028      --oxide: #d37a4c;
11029      --oxide-2: #b35428;
11030      --success-bg: #163927;
11031      --success-text: #8fe2a8;
11032      --warn-bg: #3c2d11;
11033      --warn-text: #f3cb75;
11034      --danger-bg: #3d1f1f;
11035      --danger-text: #ff9f9f;
11036      --shadow: 0 14px 28px rgba(0,0,0,0.28);
11037      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
11038    }
11039
11040    * { box-sizing: border-box; }
11041    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); }
11042    html { overflow-y: scroll; }
11043    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
11044    .top-nav, .page, .loading { position: relative; z-index: 2; }
11045    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
11046    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
11047    .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); }
11048    .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; }
11049    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
11050    .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)); }
11051    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
11052    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
11053    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
11054    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
11055    .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; }
11056    .nav-project-pill.visible { display:inline-flex; }
11057    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
11058    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
11059    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
11060    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
11061    @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; } }
11062    .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; }
11063    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
11064    .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; }
11065    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
11066    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
11067    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
11068    .theme-toggle .icon-sun { display:none; }
11069    body.dark-theme .theme-toggle .icon-sun { display:block; }
11070    body.dark-theme .theme-toggle .icon-moon { display:none; }
11071    .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;}
11072    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
11073    .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);}
11074    .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;}
11075    .settings-close:hover{color:var(--text);background:var(--surface-2);}
11076    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
11077    .settings-modal-body{padding:14px 16px 16px;}
11078    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
11079    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
11080    .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;}
11081    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
11082    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
11083    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
11084    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
11085    .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;}
11086    .tz-select:focus{border-color:var(--oxide);}
11087    .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; }
11088    .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;}
11089    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
11090    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
11091    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
11092    .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; }
11093    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
11094    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
11095    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
11096    .wb-stats-header { padding: 10px 24px 0; }
11097    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
11098    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
11099    .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; }
11100    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
11101    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
11102    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
11103    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
11104    .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; }
11105    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
11106    .ws-stat-analyzers { position: relative; }
11107    .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; }
11108    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
11109    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
11110    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
11111    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
11112    .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; }
11113    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
11114    .ws-divider { display: none; }
11115    .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%; }
11116    .ws-path-link:hover { color:var(--oxide); }
11117    body.dark-theme .ws-path-link { color:var(--oxide); }
11118    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
11119    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
11120    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
11121    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
11122    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
11123    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
11124    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
11125    .ws-mini-box-lg { flex:2 1 0; }
11126    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
11127    .ws-mini-box-br { flex:1.5 1 0; }
11128    .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); }
11129    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
11130    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
11131    #path.drag-over { background: rgba(37,99,235,0.05) !important; border-color: var(--accent) !important; box-shadow: 0 0 0 3px rgba(37,99,235,0.15) !important; }
11132    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
11133    .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; }
11134    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
11135    .git-source-banner strong { font-weight:800; color:var(--text); }
11136    .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; }
11137    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
11138    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
11139    .git-source-banner a:hover { text-decoration:underline; }
11140    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
11141    .path-scope-sep { background:var(--line); margin:4px 14px; }
11142    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
11143    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
11144    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
11145    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
11146    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
11147    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
11148    .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; }
11149    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
11150    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
11151    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
11152    .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; }
11153    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
11154    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
11155    [data-wb-tip] { cursor:help; }
11156    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
11157    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
11158    .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; }
11159    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
11160    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
11161    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
11162    .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; }
11163    .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); }
11164    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
11165    .side-info-card { padding: 18px; }
11166    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
11167    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
11168    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
11169    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
11170    .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); }
11171    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
11172    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
11173    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
11174    .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; }
11175    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
11176    .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; }
11177    .side-stack::-webkit-scrollbar { display: none; }
11178    .step-nav { padding: 20px 16px; }
11179    .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); }
11180    .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; }
11181    .step-button:hover { background: var(--surface-2); }
11182    .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); }
11183    .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; }
11184    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
11185    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
11186    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
11187    .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); }
11188    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
11189    .step-nav-sum-row:last-child { border-bottom:none; }
11190    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
11191    .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; }
11192    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
11193    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
11194    .quick-scan-section { padding: 10px 4px 14px; }
11195    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
11196    .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; }
11197    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
11198    .quick-scan-btn:active { transform:translateY(0); }
11199    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
11200    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
11201    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
11202    @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);} }
11203    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
11204    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
11205    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
11206    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
11207    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
11208    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
11209    .step-button.done .step-check { opacity:1; }
11210    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
11211    .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; }
11212    .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; }
11213    .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; }
11214    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
11215    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
11216    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
11217    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
11218    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
11219    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
11220    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
11221    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
11222    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
11223    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
11224    .card-body { padding: 22px; }
11225    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
11226    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
11227    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
11228    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
11229    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
11230    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
11231    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
11232    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
11233    .field { min-width:0; }
11234    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
11235    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; }
11236    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); }
11237    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
11238    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); }
11239    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
11240    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
11241    .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; }
11242    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
11243    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
11244    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
11245    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
11246    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
11247    .input-group.compact { grid-template-columns: 1fr auto auto; }
11248    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
11249    .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)); }
11250    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
11251    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
11252    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
11253    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
11254    .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; }
11255    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
11256    .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; }
11257    .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); }
11258    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
11259    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
11260    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
11261    button.secondary { background: var(--surface); }
11262    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11263    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
11264    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
11265    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11266    .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); }
11267    .section + .wizard-actions { border-top: none; padding-top: 0; }
11268    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
11269    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11270    .field-help-grid.coupled-help { margin-top: 12px; }
11271    .field-help-grid.preset-grid { align-items: start; }
11272    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
11273    .preset-inline-row .field { margin: 0; }
11274    .preset-inline-row .explainer-card { margin: 0; }
11275    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
11276    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
11277    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
11278    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
11279    .preset-kv-row > :last-child { flex:1; min-width:0; }
11280    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
11281    .output-field-row .field { margin: 0; }
11282    .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; }
11283    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
11284    .step3-subtitle { margin-bottom: 10px; max-width: none; }
11285    .counting-intro { margin-bottom: 8px; max-width: none; }
11286    .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; }
11287    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
11288    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
11289    .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; }
11290    .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; }
11291    .section-spacer-top { margin-top: 28px; }
11292    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
11293    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
11294    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
11295    .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); }
11296    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
11297    .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; }
11298    .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; }
11299    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
11300    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11301    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
11302    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
11303    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
11304    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
11305    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
11306    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
11307    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
11308    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
11309    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
11310    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
11311    .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); }
11312    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
11313    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
11314    .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; }
11315    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
11316    .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; }
11317    .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; }
11318    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
11319    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
11320    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
11321    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
11322    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
11323    .advanced-rule-description strong { color: var(--text); }
11324    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
11325    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
11326    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
11327    .review-link:hover { text-decoration: underline; }
11328    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
11329    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
11330    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
11331    .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; }
11332    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
11333    .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
11334    .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
11335    body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
11336    .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
11337    body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
11338    .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; }
11339    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
11340    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
11341    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
11342    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11343    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
11344    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
11345    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
11346    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
11347    .review-card ul { padding-left: 18px; margin: 0; }
11348    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
11349    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
11350    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
11351    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
11352    .review-card { min-height: 0; }
11353    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
11354    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
11355    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
11356    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
11357    .lang-overflow-chip { position:relative; cursor:default; }
11358    .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; }
11359    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
11360    .git-inline-row { align-items:start; }
11361    .mixed-line-card { display:flex; flex-direction:column; }
11362    .preset-inline-row .toggle-card { justify-content: center; }
11363        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
11364    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
11365    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
11366    .explorer-title { font-size: 18px; font-weight: 850; }
11367    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
11368    .explorer-subtitle.wide { max-width: none; }
11369    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
11370    .better-spacing { align-items:flex-start; justify-content:flex-end; }
11371    .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; }
11372    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
11373    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
11374    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
11375    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
11376    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
11377    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
11378    .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; }
11379    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
11380    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
11381    .scope-stat-button.supported { background: var(--success-bg); }
11382    .scope-stat-button.skipped { background: var(--warn-bg); }
11383    .scope-stat-button.unsupported { background: var(--danger-bg); }
11384    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
11385    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
11386    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
11387    [data-tooltip] { position: relative; }
11388    [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); }
11389    [data-tooltip]:hover::after { display: block; }
11390    .scope-stat-button[data-tooltip] { cursor: pointer; }
11391    .badge[data-tooltip] { cursor: help; }
11392    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
11393    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
11394    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
11395    .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; }
11396    .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; }
11397    code { display:inline-block; margin-top:0; padding:2px 7px; }
11398    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11399    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
11400    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
11401    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
11402    .language-pill.muted-pill { color: var(--muted); }
11403    button.language-pill { appearance:none; cursor:pointer; }
11404    .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); }
11405    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
11406    .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; }
11407    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
11408    .file-explorer-search-row { margin-left: auto; }
11409    .explorer-filter-select { min-width: 170px; width: 170px; }
11410    .explorer-search { min-width: 300px; width: 300px; }
11411    .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); }
11412    .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; }
11413    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
11414    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
11415    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
11416    .file-explorer-tree { max-height: 640px; overflow:auto; }
11417    .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); }
11418    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
11419    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
11420    .tree-row.hidden-by-filter { display:none !important; }
11421    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
11422    .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; }
11423    .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; }
11424    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
11425    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
11426    .tree-node { display:inline-flex; align-items:center; min-width:0; }
11427    .tree-node-dir { color: var(--text); font-weight: 800; }
11428    .tree-node-supported { color: var(--success-text); }
11429    .tree-node-skipped { color: var(--warn-text); }
11430    .tree-node-unsupported { color: var(--danger-text); }
11431    .tree-node-more { color: var(--muted-2); font-style: italic; }
11432    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
11433    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
11434    .tree-status-cell { display:flex; justify-content:flex-start; }
11435    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
11436    .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; }
11437    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
11438    .preview-spinner { width:18px; height:18px; border:2.5px solid var(--line); border-top-color:var(--oxide); border-radius:50%; animation:prevSpin 0.75s linear infinite; flex:0 0 18px; }
11439    @keyframes prevSpin { to { transform:rotate(360deg); } }
11440    .preview-loading-text { flex:1; min-width:0; }
11441    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
11442    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
11443    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
11444    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
11445    .cov-scan-idle { display:none; }
11446    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
11447    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
11448    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
11449    .cov-scan-title { font-weight:600; font-size:12.5px; }
11450    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
11451    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
11452    .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; }
11453    .cov-scan-use:hover { opacity:.75; }
11454    .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; }
11455    .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; }
11456    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
11457    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
11458    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
11459    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
11460    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
11461    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
11462    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
11463    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
11464    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
11465    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
11466    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
11467    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
11468    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
11469    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
11470    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
11471    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
11472    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
11473    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
11474    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
11475    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
11476    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
11477    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
11478    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
11479    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
11480    .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); }
11481    .loading.active { display:flex; }
11482    .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; }
11483    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
11484    .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; }
11485    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
11486    .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; }
11487    .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; }
11488    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
11489    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
11490    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
11491    .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; }
11492    .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
11493    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
11494    .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
11495    .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
11496    .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; }
11497    .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; }
11498    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
11499    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
11500    .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; }
11501    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
11502    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
11503    .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; }
11504    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
11505    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
11506    .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; }
11507    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
11508    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
11509    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
11510    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
11511    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
11512    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
11513    .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; }
11514    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
11515    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
11516    .hidden { display:none !important; }
11517    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11518    .site-footer a{color:var(--muted);}
11519    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
11520    @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; } }
11521    .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;}
11522    @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));}}
11523    .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;}
11524    .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; }
11525    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
11526    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
11527    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
11528    .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; }
11529    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
11530    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
11531    .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; }
11532    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
11533    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
11534    .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; }
11535    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
11536    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
11537    .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; }
11538    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
11539    .info-icon-btn:hover { color:var(--text); }
11540    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); }
11541    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
11542    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
11543    .toast-success{display:flex;align-items:center;gap:10px;background:#e8f5ed;border:1px solid #a3d9b1;border-radius:10px;padding:10px 16px;font-size:13px;color:#1a5c35;font-weight:600;}
11544    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
11545    .toast-error{display:flex;align-items:center;gap:10px;background:#fde8e8;border:1px solid #f5a3a3;border-radius:10px;padding:10px 16px;font-size:13px;color:#7a1a1a;font-weight:600;}
11546    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
11547  </style>
11548</head>
11549<body>
11550  <div class="background-watermarks" aria-hidden="true">
11551    <img src="/images/logo/logo-text.png" alt="" />
11552    <img src="/images/logo/logo-text.png" alt="" />
11553    <img src="/images/logo/logo-text.png" alt="" />
11554    <img src="/images/logo/logo-text.png" alt="" />
11555    <img src="/images/logo/logo-text.png" alt="" />
11556    <img src="/images/logo/logo-text.png" alt="" />
11557    <img src="/images/logo/logo-text.png" alt="" />
11558    <img src="/images/logo/logo-text.png" alt="" />
11559    <img src="/images/logo/logo-text.png" alt="" />
11560    <img src="/images/logo/logo-text.png" alt="" />
11561    <img src="/images/logo/logo-text.png" alt="" />
11562    <img src="/images/logo/logo-text.png" alt="" />
11563    <img src="/images/logo/logo-text.png" alt="" />
11564    <img src="/images/logo/logo-text.png" alt="" />
11565  </div>
11566  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11567  <div class="top-nav">
11568    <div class="top-nav-inner">
11569      <a class="brand" href="/">
11570        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
11571        <div class="brand-copy">
11572          <div class="brand-title">OxideSLOC</div>
11573          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
11574        </div>
11575      </a>
11576      <div class="nav-project-slot">
11577        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
11578          <span class="nav-project-label">Project</span>
11579          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
11580        </div>
11581      </div>
11582      <div class="nav-status">
11583        <a class="nav-pill" href="/">Home</a>
11584        <div class="nav-dropdown">
11585          <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>
11586          <div class="nav-dropdown-menu">
11587            <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>
11588          </div>
11589        </div>
11590        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11591        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11592        <div class="nav-dropdown">
11593          <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>
11594          <div class="nav-dropdown-menu">
11595            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
11596          </div>
11597        </div>
11598        <div class="server-status-wrap" id="server-status-wrap">
11599          <div class="nav-pill server-online-pill" id="server-status-pill">
11600            <span class="status-dot" id="status-dot"></span>
11601            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
11602            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11603          </div>
11604          <div class="server-status-tip">
11605            {% if server_mode %}
11606            OxideSLOC is running in server mode — accessible on your LAN.
11607            {% else %}
11608            OxideSLOC is running locally — only accessible from this machine.
11609            {% endif %}
11610            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11611          </div>
11612        </div>
11613        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11614          <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>
11615        </button>
11616        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
11617          <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>
11618          <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>
11619        </button>
11620      </div>
11621    </div>
11622  </div>
11623
11624  <div class="loading" id="loading">
11625    <div class="loading-card">
11626      <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
11627      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
11628      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
11629      <div class="lc-path" id="lc-path"></div>
11630      <div class="lc-metrics" id="lc-metrics">
11631        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
11632        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
11633        <div class="lc-metric hidden" id="lc-files-card"><div class="lc-metric-label">Files</div><div class="lc-metric-value" id="lc-files">0</div></div>
11634      </div>
11635      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
11636      <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>
11637      <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>
11638      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
11639      <div class="lc-actions hidden" id="lc-actions">
11640        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
11641        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
11642      </div>
11643      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
11644        <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>
11645        Cancel scan
11646      </button>
11647    </div>
11648  </div>
11649
11650  <div class="page">
11651    <div class="workbench-strip">
11652      <div class="workbench-box wb-stats">
11653        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
11654          <span class="wb-stats-title">Analysis session</span>
11655        </div>
11656        <div class="ws-left">
11657          <div class="ws-stat ws-stat-analyzers">
11658            <span class="ws-label">Analyzers</span>
11659            <span class="ws-value">
11660              <span class="ws-badge">41 languages</span>
11661            </span>
11662            <div class="ws-lang-tooltip">
11663              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
11664              <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>
11665              <div class="ws-lang-grid">
11666                <span class="ws-lang-item">Assembly</span>
11667                <span class="ws-lang-item">C</span>
11668                <span class="ws-lang-item">C++</span>
11669                <span class="ws-lang-item">C#</span>
11670                <span class="ws-lang-item">Clojure</span>
11671                <span class="ws-lang-item">CSS</span>
11672                <span class="ws-lang-item">Dart</span>
11673                <span class="ws-lang-item">Dockerfile</span>
11674                <span class="ws-lang-item">Elixir</span>
11675                <span class="ws-lang-item">Erlang</span>
11676                <span class="ws-lang-item">F#</span>
11677                <span class="ws-lang-item">Go</span>
11678                <span class="ws-lang-item">Groovy</span>
11679                <span class="ws-lang-item">Haskell</span>
11680                <span class="ws-lang-item">HTML</span>
11681                <span class="ws-lang-item">Java</span>
11682                <span class="ws-lang-item">JavaScript</span>
11683                <span class="ws-lang-item">Julia</span>
11684                <span class="ws-lang-item">Kotlin</span>
11685                <span class="ws-lang-item">Lua</span>
11686                <span class="ws-lang-item">Makefile</span>
11687                <span class="ws-lang-item">Nim</span>
11688                <span class="ws-lang-item">Obj-C</span>
11689                <span class="ws-lang-item">OCaml</span>
11690                <span class="ws-lang-item">Perl</span>
11691                <span class="ws-lang-item">PHP</span>
11692                <span class="ws-lang-item">PowerShell</span>
11693                <span class="ws-lang-item">Python</span>
11694                <span class="ws-lang-item">R</span>
11695                <span class="ws-lang-item">Ruby</span>
11696                <span class="ws-lang-item">Rust</span>
11697                <span class="ws-lang-item">Scala</span>
11698                <span class="ws-lang-item">SCSS</span>
11699                <span class="ws-lang-item">Shell</span>
11700                <span class="ws-lang-item">SQL</span>
11701                <span class="ws-lang-item">Svelte</span>
11702                <span class="ws-lang-item">Swift</span>
11703                <span class="ws-lang-item">TypeScript</span>
11704                <span class="ws-lang-item">Vue</span>
11705                <span class="ws-lang-item">XML</span>
11706                <span class="ws-lang-item">Zig</span>
11707              </div>
11708            </div>
11709          </div>
11710          <div class="ws-divider"></div>
11711          <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>
11712          <div class="ws-divider"></div>
11713          <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.">
11714            <span class="ws-label">Output</span>
11715            <span class="ws-value">
11716              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
11717                <span id="ws-output-root">project/sloc</span>
11718              </button>
11719            </span>
11720          </div>
11721        </div>
11722      </div>
11723      <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.">
11724        <div class="ws-history-label">Scan history</div>
11725        <div class="ws-history-inner">
11726          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
11727            <div class="ws-mini-label">Scans</div>
11728            <div class="ws-mini-value" id="ws-scan-count">—</div>
11729          </div>
11730          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
11731            <div class="ws-mini-label">Last Scan</div>
11732            <div class="ws-mini-value" id="ws-last-scan">—</div>
11733          </div>
11734          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
11735            <div class="ws-mini-label">Branch</div>
11736            <div class="ws-mini-value" id="ws-branch">—</div>
11737          </div>
11738        </div>
11739      </div>
11740    </div>
11741
11742    <div class="layout">
11743      <aside class="side-stack">
11744        <section class="step-nav">
11745        <h3>Guided scan setup</h3>
11746        <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>
11747        <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>
11748        <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>
11749        <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>
11750
11751        <div class="step-steps-divider"></div>
11752
11753        <div class="step-nav-info" id="step-nav-info">
11754          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
11755          <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>
11756        </div>
11757
11758        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
11759          <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>
11760          <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>
11761          <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>
11762        </div>
11763
11764        <div class="quick-scan-divider"></div>
11765        <div class="quick-scan-section">
11766          <div class="quick-scan-label">No customization needed?</div>
11767          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
11768            <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>
11769            Quick Scan
11770          </button>
11771          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
11772        </div>
11773
11774        <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>
11775        </section>
11776
11777      </aside>
11778
11779      <section class="card">
11780        <div class="card-header">
11781          <div class="card-title-row">
11782            <div>
11783              <h1 class="card-title">Guided scan configuration</h1>
11784              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
11785            </div>
11786            <div class="wizard-progress" aria-label="Scan setup progress">
11787              <div class="wizard-progress-top">
11788                <span class="wizard-progress-label">Setup progress</span>
11789                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
11790              </div>
11791              <div class="wizard-progress-track">
11792                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
11793              </div>
11794            </div>
11795          </div>
11796        </div>
11797        <div class="card-body">
11798          <form method="post" action="/analyze" id="analyze-form">
11799            <div class="wizard-step active" data-step="1">
11800              <div class="section">
11801                <div class="section-kicker">Step 1</div>
11802                <h2>Select project and preview scope</h2>
11803                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
11804                <div class="field">
11805                  <label for="path">Project path</label>
11806                  {% if !git_repo.is_empty() %}
11807                  <div class="git-source-banner">
11808                    <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>
11809                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
11810                    <a href="/git-browser">← Back to Git Browser</a>
11811                  </div>
11812                  {% endif %}
11813                  <div class="path-scope-grid">
11814                      {% if !git_repo.is_empty() %}
11815                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
11816                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
11817                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
11818                      {% else %}
11819                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
11820                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
11821                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
11822                      {% endif %}
11823                    <div class="path-scope-sep"></div>
11824                    <div class="scope-legend-row">
11825                      <span class="scope-legend-label">Scope legend:</span>
11826                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
11827                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
11828                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
11829                    </div>
11830                  </div>
11831                  {% if git_repo.is_empty() %}
11832                  {% if server_mode %}
11833                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
11834                    ℹ️ Files are compressed and streamed — no fixed size limit.
11835                  </div>
11836                  {% endif %}
11837                  <div class="path-info-row">
11838                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
11839                      <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>
11840                      <span id="project-size-text">Project size: —</span>
11841                    </button>
11842                  </div>
11843                  {% else %}
11844                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
11845                  {% endif %}
11846                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
11847                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
11848                </div>
11849
11850                <div class="scope-preview-divider" aria-hidden="true"></div>
11851
11852                <div id="preview-panel">
11853                  <div class="preview-error">Loading preview...</div>
11854                </div>
11855              </div>
11856
11857              <div class="section" style="margin-top:14px;">
11858                <div class="preset-inline-row git-inline-row">
11859                  <div class="toggle-card" style="margin:0;">
11860                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
11861                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
11862                    <label class="checkbox">
11863                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
11864                      <div>
11865                        <span>Detect and separate git submodules</span>
11866                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
11867                      </div>
11868                    </label>
11869                  </div>
11870                  <div class="explainer-card prominent" style="margin:0;">
11871                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11872                    <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>
11873                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
11874    path = libs/core
11875    url  = https://github.com/org/core.git
11876
11877[submodule "libs/ui"]
11878    path = libs/ui
11879    url  = https://github.com/org/ui.git</div>
11880                  </div>
11881                </div>
11882              </div>
11883
11884              <div class="section">
11885                <div class="field-grid">
11886                  <div class="field">
11887                    <label for="include_globs">Include globs</label>
11888                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
11889                    <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>
11890                  </div>
11891                  <div class="field">
11892                    <label for="exclude_globs">Exclude globs</label>
11893                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
11894                    <div id="quick-exclude-chips" class="quick-excl-row">
11895                      <span class="quick-excl-label">Quick add:</span>
11896                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
11897                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
11898                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
11899                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
11900                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
11901                      <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>
11902                    </div>
11903                    <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>
11904                  </div>
11905                </div>
11906                <div class="glob-guidance-grid">
11907                  <div class="glob-guidance-card">
11908                    <strong>How to read them</strong>
11909                    <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>
11910                  </div>
11911                  <div class="glob-guidance-card">
11912                    <strong>Common include examples</strong>
11913                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
11914                  </div>
11915                  <div class="glob-guidance-card">
11916                    <strong>Common exclude examples</strong>
11917                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
11918                  </div>
11919                </div>
11920              </div>
11921
11922              <div class="section" style="margin-top:14px;">
11923                <div class="preset-inline-row git-inline-row">
11924                  <div class="toggle-card" style="margin:0;">
11925                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
11926                    <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>
11927                    <div class="field" style="margin:0;">
11928                      <div class="input-group compact">
11929                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
11930                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
11931                      </div>
11932                      <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>
11933                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
11934                    </div>
11935                  </div>
11936                  <div class="explainer-card prominent" style="margin:0;">
11937                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11938                    <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>
11939                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
11940lcov --capture --directory . --output-file coverage/lcov.info
11941
11942# C / C++ — llvm-cov (LCOV)
11943llvm-profdata merge -sparse default.profraw -o default.profdata
11944llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
11945
11946# C# — coverlet (Cobertura XML)
11947dotnet test --collect:"XPlat Code Coverage"
11948
11949# Python — pytest-cov (Cobertura XML)
11950pytest --cov --cov-report=xml
11951
11952# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
11953./gradlew jacocoTestReport</div>
11954                  </div>
11955                </div>
11956              </div>
11957
11958              <div class="wizard-actions">
11959                <div class="left"></div>
11960                <div class="right">
11961                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
11962                </div>
11963              </div>
11964            </div>
11965
11966            <div class="wizard-step" data-step="2">
11967              <div class="section">
11968                <div class="section-kicker">Step 2</div>
11969                <h2>Choose counting behavior</h2>
11970                <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>
11971<div class="subsection-bar">Primary line classification</div>
11972                <div class="preset-kv-row">
11973                  <div class="toggle-card mixed-line-card" style="margin:0;">
11974                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
11975                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
11976                    <select id="mixed_line_policy" name="mixed_line_policy">
11977                      <option value="code_only">Code only</option>
11978                      <option value="code_and_comment">Code and comment</option>
11979                      <option value="comment_only">Comment only</option>
11980                      <option value="separate_mixed_category">Separate mixed category</option>
11981                    </select>
11982                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
11983                  </div>
11984                  <div class="explainer-card prominent" style="margin:0;">
11985                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
11986                    <div class="explainer-body" id="mixed-policy-description"></div>
11987                    <div class="code-sample" id="mixed-policy-example"></div>
11988                  </div>
11989                </div>
11990              </div>
11991
11992              <div class="subsection-bar">Additional scan rules</div>
11993              <div class="scan-rules-grid">
11994                <div class="preset-inline-row">
11995                  <div class="toggle-card" style="margin:0;">
11996                    <div class="field-help-title">Generated files</div>
11997                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
11998                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11999                  </div>
12000                  <div class="explainer-card prominent" style="margin:0;">
12001                    <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>
12002                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
12003# Files matching codegen patterns are excluded:
12004#   *.generated.cs  *.pb.go  *.g.dart</div>
12005                  </div>
12006                </div>
12007                <div class="preset-inline-row">
12008                  <div class="toggle-card" style="margin:0;">
12009                    <div class="field-help-title">Minified files</div>
12010                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
12011                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12012                  </div>
12013                  <div class="explainer-card prominent" style="margin:0;">
12014                    <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>
12015                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
12016# Heuristic: very long lines + low whitespace ratio
12017#   jquery.min.js  bundle.min.css  → skipped</div>
12018                  </div>
12019                </div>
12020                <div class="preset-inline-row">
12021                  <div class="toggle-card" style="margin:0;">
12022                    <div class="field-help-title">Vendor directories</div>
12023                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
12024                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12025                  </div>
12026                  <div class="explainer-card prominent" style="margin:0;">
12027                    <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>
12028                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
12029# Directories named vendor/ node_modules/ third_party/
12030#   → entire subtree is excluded from totals</div>
12031                  </div>
12032                </div>
12033                <div class="preset-inline-row">
12034                  <div class="toggle-card" style="margin:0;">
12035                    <div class="field-help-title">Lockfiles and manifests</div>
12036                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
12037                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
12038                  </div>
12039                  <div class="explainer-card prominent" style="margin:0;">
12040                    <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>
12041                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
12042# Files like package-lock.json  Cargo.lock  yarn.lock
12043#   → skipped unless this is enabled</div>
12044                  </div>
12045                </div>
12046                <div class="preset-inline-row">
12047                  <div class="toggle-card" style="margin:0;">
12048                    <div class="field-help-title">Binary handling</div>
12049                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
12050                    <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>
12051                  </div>
12052                  <div class="explainer-card prominent" style="margin:0;">
12053                    <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>
12054                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
12055# Detected via long lines + low whitespace heuristic
12056#   .png  .exe  .so  → skipped silently</div>
12057                  </div>
12058                </div>
12059                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
12060                  <div class="toggle-card" style="margin:0;">
12061                    <div class="field-help-title">Python docstrings</div>
12062                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
12063                    <label class="checkbox">
12064                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
12065                      <span>Count as comment-style lines</span>
12066                    </label>
12067                  </div>
12068                  <div class="explainer-card prominent" style="margin:0;">
12069                    <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>
12070                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
12071                  </div>
12072                </div>
12073              </div>
12074              <div class="subsection-bar">IEEE 1045-1992 counting</div>
12075              <div class="scan-rules-grid">
12076                <div class="preset-inline-row">
12077                  <div class="toggle-card" style="margin:0;">
12078                    <div class="field-help-title">Continuation lines</div>
12079                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
12080                    <select name="continuation_line_policy" id="continuation_line_policy">
12081                      <option value="each_physical_line" selected>Each physical line (default)</option>
12082                      <option value="collapse_to_logical">Collapse to logical line</option>
12083                    </select>
12084                  </div>
12085                  <div class="explainer-card prominent" style="margin:0;">
12086                    <div class="advanced-rule-description"><strong>Purpose:</strong> Controls how backslash-continued lines (C macros, shell, Makefile) are counted.<br /><strong>Each physical line</strong> — the IEEE 1045-1992 default; every line with content is counted separately.<br /><strong>Collapse to logical</strong> — a backslash-continued sequence counts as one logical line, matching logical-SLOC conventions.</div>
12087                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
12088    ((a) &gt; (b) ? (a) : (b))
12089# each_physical_line → 2 SLOC
12090# collapse_to_logical → 1 SLOC</div>
12091                  </div>
12092                </div>
12093                <div class="preset-inline-row">
12094                  <div class="toggle-card" style="margin:0;">
12095                    <div class="field-help-title">Block-comment blanks</div>
12096                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
12097                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
12098                      <option value="count_as_comment" selected>Count as comment (default)</option>
12099                      <option value="count_as_blank">Count as blank</option>
12100                    </select>
12101                  </div>
12102                  <div class="explainer-card prominent" style="margin:0;">
12103                    <div class="advanced-rule-description"><strong>Purpose:</strong> Decides how blank lines that fall inside a <code style="font-size:12px;">/* … */</code> block comment are classified.<br /><strong>Count as comment</strong> — IEEE-aligned; blank lines are part of the comment body.<br /><strong>Count as blank</strong> — legacy behaviour; blank lines inside block comments are treated as ordinary blank lines.</div>
12104                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
12105 * Summary line
12106 *              ← blank inside block comment
12107 * Detail line
12108 */
12109# count_as_comment → blank counts toward comments
12110# count_as_blank   → blank counts toward blanks</div>
12111                  </div>
12112                </div>
12113                <div class="preset-inline-row">
12114                  <div class="toggle-card" style="margin:0;">
12115                    <div class="field-help-title">Compiler directives</div>
12116                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
12117                    <select name="count_compiler_directives" id="count_compiler_directives">
12118                      <option value="enabled" selected>Include in code SLOC (default)</option>
12119                      <option value="disabled">Exclude from code SLOC</option>
12120                    </select>
12121                  </div>
12122                  <div class="explainer-card prominent" style="margin:0;">
12123                    <div class="advanced-rule-description"><strong>Purpose:</strong> IEEE 1045-1992 §4.2 — controls whether preprocessor directives contribute to code SLOC. Applies to C, C++, and Objective-C.<br /><strong>Include</strong> — <code style="font-size:12px;">#include</code> / <code style="font-size:12px;">#define</code> lines count toward code SLOC (default).<br /><strong>Exclude</strong> — directives are tracked separately in raw counts but not added to effective code SLOC; useful when comparing with tools that strip the preprocessor layer.</div>
12124                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
12125#define BUF 256     ← compiler directive
12126int main() { … }   ← code
12127# enabled  → 3 code SLOC
12128# disabled → 1 code SLOC + 2 directive lines</div>
12129                  </div>
12130                </div>
12131              </div>
12132
12133              <div class="always-tracked-tip">
12134                <div class="always-tracked-tip-icon">ℹ</div>
12135                <div class="always-tracked-tip-body">
12136                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
12137                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
12138                  <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>
12139                </div>
12140              </div>
12141
12142              <div class="wizard-actions">
12143                <div class="left">
12144                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
12145                </div>
12146                <div class="right">
12147                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
12148                </div>
12149              </div>
12150            </div>
12151
12152            <div class="wizard-step" data-step="3">
12153              <div class="section">
12154                <div class="section-kicker">Step 3</div>
12155                <h2>Output and report identity</h2>
12156                <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>
12157                <div class="preset-kv-row">
12158                  <div class="toggle-card" style="margin:0;">
12159                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
12160                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
12161                    <select id="scan_preset">
12162                      <option value="balanced">Balanced local scan</option>
12163                      <option value="code_focused">Code focused</option>
12164                      <option value="comment_audit">Comment audit</option>
12165                      <option value="deep_review">Deep review</option>
12166                    </select>
12167                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
12168                  </div>
12169                  <div class="explainer-card">
12170                    <div class="field-help-title">Selected scan preset</div>
12171                    <div class="explainer-body" id="scan-preset-description"></div>
12172                    <div class="preset-summary-row" id="scan-preset-summary"></div>
12173                    <div class="code-sample" id="scan-preset-example"></div>
12174                    <div class="preset-note" id="scan-preset-note"></div>
12175                  </div>
12176                </div>
12177                <hr class="step3-separator" />
12178                <div class="preset-kv-row">
12179                  <div class="toggle-card" style="margin:0;">
12180                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
12181                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
12182                    <select id="artifact_preset">
12183                      <option value="review">Review bundle</option>
12184                      <option value="full">Full bundle</option>
12185                      <option value="html_only">HTML only</option>
12186                      <option value="machine">Machine bundle</option>
12187                    </select>
12188                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
12189                  </div>
12190                  <div class="explainer-card">
12191                    <div class="field-help-title">Selected artifact preset</div>
12192                    <div class="explainer-body" id="artifact-preset-description"></div>
12193                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
12194                    <div class="code-sample" id="artifact-preset-example"></div>
12195                  </div>
12196                </div>
12197              </div>
12198
12199              <div class="section section-spacer-top">
12200                <div class="output-field-row">
12201                  <div class="field">
12202                    <label for="output_dir">Output directory</label>
12203                    {% if server_mode %}
12204                    <div class="input-group compact">
12205                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" readonly style="cursor:default;opacity:0.68;background:var(--surface-2);" />
12206                    </div>
12207                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
12208                    {% else %}
12209                    <div class="input-group compact">
12210                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
12211                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
12212                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
12213                    </div>
12214                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
12215                    {% endif %}
12216                  </div>
12217                  <div class="output-field-aside">
12218                    <strong>Where reports land</strong>
12219                    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.
12220                  </div>
12221                </div>
12222              </div>
12223
12224              <div class="section section-spacer-top">
12225                <div class="output-field-row">
12226                  <div class="field">
12227                    <label for="report_title">Report title</label>
12228                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
12229                    <div class="hint">Appears in HTML and PDF output headers.</div>
12230                  </div>
12231                  <div class="output-field-aside">
12232                    <strong>Shown in exported artifacts</strong>
12233                    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.
12234                  </div>
12235                </div>
12236              </div>
12237
12238              <div class="section section-spacer-top">
12239                <div class="output-field-row">
12240                  <div class="field">
12241                    <label for="report_header_footer">Report header / footer</label>
12242                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
12243                    <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>
12244                  </div>
12245                  <div class="output-field-aside">
12246                    <strong>Page-level identification</strong>
12247                    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.
12248                  </div>
12249                </div>
12250              </div>
12251
12252              <div class="section">
12253                <div class="section-kicker">Artifacts</div>
12254                <div class="artifact-grid" style="margin-bottom:24px;">
12255                  <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
12256                    <div class="marker">✓</div>
12257                    <div class="artifact-icon">H</div>
12258                    <h4>HTML report</h4>
12259                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
12260                    <div class="artifact-tags">
12261                      <span class="soft-chip">Best for visual review</span>
12262                      <span class="soft-chip">Embeddable preview</span>
12263                    </div>
12264                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
12265                  </div>
12266                  <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
12267                    <div class="marker">✓</div>
12268                    <div class="artifact-icon">P</div>
12269                    <h4>PDF export</h4>
12270                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
12271                    <div class="artifact-tags">
12272                      <span class="soft-chip">Portable snapshot</span>
12273                      <span class="soft-chip">Good for handoff</span>
12274                    </div>
12275                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
12276                  </div>
12277                  <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
12278                    <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>
12279                    <div class="marker">✓</div>
12280                    <div class="artifact-icon" style="color:var(--muted);">J</div>
12281                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
12282                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
12283                    <div class="artifact-tags">
12284                      <span class="soft-chip">Required for compare</span>
12285                      <span class="soft-chip">Auto-enabled</span>
12286                    </div>
12287                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
12288                  </div>
12289                </div>
12290                <div class="hint" style="margin-top:12px;">HTML and PDF cards are selectable. Presets above can also toggle them for common workflows. JSON output is always generated.</div>
12291              </div>
12292
12293              <div class="wizard-actions">
12294                <div class="left">
12295                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
12296                </div>
12297                <div class="right">
12298                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
12299                </div>
12300              </div>
12301            </div>
12302
12303            <div class="wizard-step" data-step="4">
12304              <div class="section">
12305                <div class="section-kicker">Step 4</div>
12306                <h2>Review selections and run</h2>
12307                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
12308                <div class="review-grid">
12309                  <div class="review-card highlight">
12310                    <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>
12311                    <ul id="review-scan-summary"></ul>
12312                  </div>
12313                  <div class="review-card highlight">
12314                    <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>
12315                    <ul id="review-count-summary"></ul>
12316                  </div>
12317                  <div class="review-card">
12318                    <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>
12319                    <ul id="review-artifact-summary"></ul>
12320                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
12321                  </div>
12322                  <div class="review-card">
12323                    <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>
12324                    <ul id="review-preview-summary"></ul>
12325                  </div>
12326                </div>
12327              </div>
12328
12329              <div class="wizard-actions">
12330                <div class="left">
12331                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
12332                </div>
12333                <div class="right">
12334                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
12335                </div>
12336              </div>
12337            </div>
12338            {% if server_mode %}
12339            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
12340            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
12341            {% endif %}
12342          </form>
12343        </div>
12344      </section>
12345    </div>
12346  </div>
12347
12348  <script nonce="{{ csp_nonce }}">
12349    (function () {
12350      function startScanPhase() {
12351        var phaseEl = document.getElementById("scan-phase");
12352        if (!phaseEl) return;
12353        var phases = [
12354          "Discovering files...",
12355          "Decoding file encodings...",
12356          "Detecting languages...",
12357          "Analyzing source lines...",
12358          "Applying counting policies...",
12359          "Aggregating results...",
12360          "Rendering report..."
12361        ];
12362        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
12363        var i = 0;
12364        function next() {
12365          phaseEl.style.opacity = "0";
12366          setTimeout(function () {
12367            phaseEl.textContent = phases[i];
12368            phaseEl.style.opacity = "0.85";
12369            var delay = durations[i] || 1800;
12370            i++;
12371            if (i < phases.length) { setTimeout(next, delay); }
12372          }, 200);
12373        }
12374        next();
12375      }
12376
12377      var form = document.getElementById("analyze-form");
12378      var loading = document.getElementById("loading");
12379      var submitButton = document.getElementById("submit-button");
12380      var pathInput = document.getElementById("path");
12381      var GIT_MODE = !!(pathInput && pathInput.readOnly);
12382      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
12383      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
12384      var outputDirInput = document.getElementById("output_dir");
12385      var reportTitleInput = document.getElementById("report_title");
12386      var previewPanel = document.getElementById("preview-panel");
12387      var refreshButton = document.getElementById("refresh-preview");
12388      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
12389      var useSamplePath = document.getElementById("use-sample-path");
12390      var useDefaultOutput = document.getElementById("use-default-output");
12391      var browsePath = document.getElementById("browse-path");
12392      var browseOutputDir = document.getElementById("browse-output-dir");
12393      var browseCoverage = document.getElementById("browse-coverage");
12394      var coverageInput = document.getElementById("coverage_file");
12395      var covScanStatus = document.getElementById("cov-scan-status");
12396      var coverageSuggestTimer = null;
12397      var covAutoFilled = false;
12398      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
12399      function fmtBytes(b) {
12400        b = Number(b) || 0;
12401        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
12402        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
12403        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
12404        return b + ' B';
12405      }
12406      var themeToggle = document.getElementById("theme-toggle");
12407
12408      function showBannerToast(msg, isError, opts) {
12409        opts = opts || {};
12410        var t = document.createElement('div');
12411        t.className = isError ? 'toast-error' : 'toast-success';
12412        var topPos = opts.top ? '80px' : null;
12413        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
12414          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
12415          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
12416          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
12417        if (opts.icon) {
12418          var inner = document.createElement('span');
12419          inner.innerHTML = opts.icon + ' ';
12420          t.appendChild(inner);
12421        }
12422        t.appendChild(document.createTextNode(msg));
12423        document.body.appendChild(t);
12424        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
12425      }
12426      var mixedLinePolicy = document.getElementById("mixed_line_policy");
12427      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
12428      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
12429      var scanPreset = document.getElementById("scan_preset");
12430      var artifactPreset = document.getElementById("artifact_preset");
12431      var includeGlobsInput = document.getElementById("include_globs");
12432      var excludeGlobsInput = document.getElementById("exclude_globs");
12433
12434      // Quick-exclude chips — append pattern to exclude_globs textarea.
12435      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
12436        chip.addEventListener("click", function() {
12437          var pattern = chip.getAttribute("data-pattern") || "";
12438          if (!pattern || !excludeGlobsInput) return;
12439          var current = excludeGlobsInput.value.trim();
12440          // For the "skip all" chip, replace any existing dep patterns cleanly.
12441          var patterns = pattern.split("\n");
12442          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
12443          var added = false;
12444          patterns.forEach(function(p) {
12445            p = p.trim();
12446            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
12447          });
12448          if (added) {
12449            excludeGlobsInput.value = lines.join("\n");
12450            excludeGlobsInput.dispatchEvent(new Event("input"));
12451          }
12452          chip.classList.add("active");
12453        });
12454      });
12455
12456      var liveReportTitle = document.getElementById("live-report-title");
12457      var navProjectPill = document.getElementById("nav-project-pill");
12458      var navProjectTitle = document.getElementById("nav-project-title");
12459      var reportTitlePreview = null;
12460      var wizardProgressFill = document.getElementById("wizard-progress-fill");
12461      var wizardProgressValue = document.getElementById("wizard-progress-value");
12462      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
12463      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
12464      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
12465      var reportTitleTouched = false;
12466      var currentStep = 1;
12467      var previewTimer = null;
12468      var quickScanBtn = document.getElementById("quick-scan-btn");
12469
12470      function dismissAnalysisModal() {
12471        if (loading) loading.classList.remove("active");
12472        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12473          var el = document.getElementById(id);
12474          if (el) el.classList.add("hidden");
12475        });
12476        var cancelBtn = document.getElementById("lc-cancel-btn");
12477        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
12478        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
12479        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
12480        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12481        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12482        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12483        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12484        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12485      }
12486
12487      var lcDismissBtn = document.getElementById("lc-dismiss");
12488      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
12489
12490      function startAsyncAnalysis(formData) {
12491        var gitRepo = (formData.get("git_repo") || "").toString();
12492        var gitRef  = (formData.get("git_ref")  || "").toString();
12493        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
12494        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
12495
12496        var pathEl = document.getElementById("lc-path");
12497        if (pathEl) pathEl.textContent = displayPath;
12498
12499        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12500          var el = document.getElementById(id);
12501          if (el) el.classList.add("hidden");
12502        });
12503        var cancelBtn = document.getElementById("lc-cancel-btn");
12504        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
12505        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12506        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12507        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12508        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
12509        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
12510
12511        if (loading) loading.classList.add("active");
12512
12513        var startTime = Date.now();
12514        var elapsedTimer = setInterval(function() {
12515          var s = Math.floor((Date.now() - startTime) / 1000);
12516          var el = document.getElementById("lc-elapsed");
12517          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
12518        }, 1000);
12519
12520        var warnShown = false, pollRetries = 0, activeWaitId = null;
12521
12522        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();}
12523
12524        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
12525
12526        function lcShowCancelled() {
12527          clearInterval(elapsedTimer);
12528          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
12529          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
12530          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
12531          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
12532          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
12533          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
12534          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
12535          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
12536          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12537          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12538        }
12539
12540        var lcCancelBtn = document.getElementById("lc-cancel-btn");
12541        if (lcCancelBtn) {
12542          lcCancelBtn.onclick = function() {
12543            if (!activeWaitId) { dismissAnalysisModal(); return; }
12544            lcCancelBtn.disabled = true;
12545            lcCancelBtn.textContent = "Cancelling…";
12546            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
12547              .then(function() { lcShowCancelled(); })
12548              .catch(function() { lcShowCancelled(); });
12549          };
12550        }
12551
12552        function lcShowError(msg) {
12553          clearInterval(elapsedTimer);
12554          lcSetPhase("Failed");
12555          var msgEl = document.getElementById("lc-err-msg");
12556          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
12557          var errEl = document.getElementById("lc-err");
12558          var actEl = document.getElementById("lc-actions");
12559          if (errEl) errEl.classList.remove("hidden");
12560          if (actEl) actEl.classList.remove("hidden");
12561          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12562          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12563        }
12564
12565        function lcPoll(waitId) {
12566          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
12567            .then(function(r) {
12568              if (!r.ok) throw new Error("HTTP " + r.status);
12569              return r.json();
12570            })
12571            .then(function(data) {
12572              pollRetries = 0;
12573              if (data.state === "complete") {
12574                clearInterval(elapsedTimer);
12575                lcSetPhase("Done");
12576                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
12577              } else if (data.state === "failed") {
12578                lcShowError(data.message);
12579              } else if (data.state === "cancelled") {
12580                lcShowCancelled();
12581              } else {
12582                var s = Math.floor((Date.now() - startTime) / 1000);
12583                if (s > 90 && !warnShown) {
12584                  warnShown = true;
12585                  var w = document.getElementById("lc-warn");
12586                  if (w) w.classList.remove("hidden");
12587                }
12588                lcSetPhase(data.phase || "Running");
12589                var fd = data.files_done || 0, ft = data.files_total || 0;
12590                if (ft > 0) {
12591                  var card = document.getElementById("lc-files-card");
12592                  if (card) card.classList.remove("hidden");
12593                  var el = document.getElementById("lc-files");
12594                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
12595                }
12596                setTimeout(function() { lcPoll(waitId); }, 1500);
12597              }
12598            })
12599            .catch(function() {
12600              pollRetries++;
12601              if (pollRetries >= 5) {
12602                lcShowError("Lost connection to server. Reload to check status.");
12603              } else {
12604                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
12605              }
12606            });
12607        }
12608
12609        var params = new URLSearchParams(formData);
12610        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
12611          .then(function(r) {
12612            var waitId = r.headers.get("x-wait-id");
12613            if (!waitId) { window.location.href = "/scan"; return; }
12614            activeWaitId = waitId;
12615            setTimeout(function() { lcPoll(waitId); }, 1500);
12616          })
12617          .catch(function(err) {
12618            lcShowError("Could not reach server: " + (err.message || err));
12619          });
12620      }
12621
12622      if (quickScanBtn) {
12623        quickScanBtn.addEventListener("click", function () {
12624          var pathVal = pathInput ? pathInput.value.trim() : "";
12625          if (!pathVal) {
12626            alert("Please enter or browse to a project path first.");
12627            return;
12628          }
12629          quickScanBtn.disabled = true;
12630          quickScanBtn.textContent = "Scanning...";
12631          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
12632          startAsyncAnalysis(new FormData(form));
12633        });
12634      }
12635
12636      var mixedPolicyInfo = {
12637        code_only: {
12638          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.",
12639          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'
12640        },
12641        code_and_comment: {
12642          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.",
12643          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'
12644        },
12645        comment_only: {
12646          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.",
12647          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'
12648        },
12649        separate_mixed_category: {
12650          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.",
12651          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'
12652        }
12653      };
12654
12655      var scanPresetInfo = {
12656        balanced: {
12657          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.",
12658          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
12659          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
12660          note: "Best when you want a stable local overview before making deeper adjustments.",
12661          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12662        },
12663        code_focused: {
12664          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
12665          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
12666          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
12667          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
12668          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12669        },
12670        comment_audit: {
12671          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
12672          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
12673          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
12674          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
12675          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12676        },
12677        deep_review: {
12678          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
12679          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
12680          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
12681          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
12682          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
12683        }
12684      };
12685
12686      var artifactPresetInfo = {
12687        review: {
12688          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.",
12689          chips: ["HTML", "PDF"],
12690          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
12691        },
12692        full: {
12693          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.",
12694          chips: ["HTML", "PDF", "JSON"],
12695          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
12696        },
12697        html_only: {
12698          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.",
12699          chips: ["HTML only", "Fast local review"],
12700          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
12701        },
12702        machine: {
12703          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
12704          chips: ["HTML", "JSON"],
12705          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
12706        }
12707      };
12708
12709      function applyTheme(theme) {
12710        if (theme === "dark") document.body.classList.add("dark-theme");
12711        else document.body.classList.remove("dark-theme");
12712      }
12713
12714      function loadSavedTheme() {
12715        var saved = null;
12716        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
12717        applyTheme(saved === "dark" ? "dark" : "light");
12718      }
12719
12720      function updateScrollProgress() {
12721        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
12722        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
12723        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
12724        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
12725        var step = Math.min(Math.max(currentStep, 1), 4);
12726        var base = stepBase[step];
12727        var end  = stepEnd[step];
12728
12729        var scrollFrac = 0;
12730        var activePanel = document.querySelector(".wizard-step.active");
12731        if (activePanel) {
12732          var scrollTop = window.scrollY || window.pageYOffset || 0;
12733          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
12734          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
12735          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
12736          var scrolled = scrollTop + viewH - panelTop;
12737          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
12738        }
12739
12740        var percent = Math.round(base + (end - base) * scrollFrac);
12741        percent = Math.min(end, Math.max(base, percent));
12742        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
12743        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
12744      }
12745
12746      function updateWizardProgress() {
12747        updateScrollProgress();
12748      }
12749
12750      var stepDescriptions = [
12751        "Choose a project folder, apply scope filters, and preview which files will be counted.",
12752        "Configure how mixed code-plus-comment lines and docstrings are classified.",
12753        "Pick your output formats, scan preset, and where reports are saved.",
12754        "Review all settings and launch the analysis."
12755      ];
12756
12757      function updateStepNav(step) {
12758        var infoLabel = document.getElementById("step-nav-info-label");
12759        var infoDesc  = document.getElementById("step-nav-info-desc");
12760        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
12761        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
12762      }
12763
12764      function updateSidebarSummary() {
12765        var sumPath    = document.getElementById("sum-path");
12766        var sumPreset  = document.getElementById("sum-preset");
12767        var sumOutput  = document.getElementById("sum-output");
12768        var sidebarSummary = document.getElementById("sidebar-summary");
12769        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
12770        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
12771        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
12772        if (sumPath)   sumPath.textContent   = pathVal   || "—";
12773        if (sumPreset) sumPreset.textContent = presetVal || "—";
12774        if (sumOutput) sumOutput.textContent = outputVal || "—";
12775        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
12776      }
12777
12778      function setStep(step, pushHistory) {
12779        currentStep = step;
12780        stepPanels.forEach(function (panel) {
12781          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
12782        });
12783        stepButtons.forEach(function (button) {
12784          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
12785        });
12786        var layoutEl = document.querySelector(".layout");
12787        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
12788        updateWizardProgress();
12789        updateStepNav(step);
12790        stepButtons.forEach(function(btn) {
12791          var t = Number(btn.getAttribute("data-step-target"));
12792          btn.classList.toggle("done", t < step);
12793        });
12794        updateSidebarSummary();
12795
12796        if (pushHistory !== false) {
12797          try {
12798            history.pushState({ wizardStep: step }, "", "#step" + step);
12799          } catch (e) {}
12800        }
12801
12802        window.scrollTo({ top: 0, behavior: "instant" });
12803      }
12804
12805      window.addEventListener("popstate", function (e) {
12806        if (e.state && e.state.wizardStep) {
12807          setStep(e.state.wizardStep, false);
12808        } else {
12809          var hashMatch = location.hash.match(/^#step([1-4])$/);
12810          if (hashMatch) setStep(Number(hashMatch[1]), false);
12811        }
12812      });
12813
12814      function inferTitleFromPath(value) {
12815        if (!value) return "project";
12816        var cleaned = value.replace(/[\/\\]+$/, "");
12817        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
12818        return parts.length ? parts[parts.length - 1] : value;
12819      }
12820
12821      function updateReportTitleFromPath() {
12822        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
12823        if (!reportTitleTouched) {
12824          reportTitleInput.value = inferred;
12825        }
12826        var title = reportTitleInput.value || inferred;
12827        if (liveReportTitle) liveReportTitle.textContent = title;
12828        if (reportTitlePreview) reportTitlePreview.textContent = title;
12829        document.title = "OxideSLOC | " + title;
12830
12831        var projectPath = (pathInput.value || "").trim();
12832        if (navProjectPill && navProjectTitle) {
12833          if (projectPath.length > 0) {
12834            navProjectTitle.textContent = inferred;
12835            navProjectPill.classList.add("visible");
12836          } else {
12837            navProjectTitle.textContent = "";
12838            navProjectPill.classList.remove("visible");
12839          }
12840        }
12841      }
12842
12843      function updateMixedPolicyUI() {
12844        var key = mixedLinePolicy.value || "code_only";
12845        var info = mixedPolicyInfo[key];
12846        document.getElementById("mixed-policy-description").textContent = info.description;
12847        document.getElementById("mixed-policy-example").textContent = info.example;
12848      }
12849
12850      function updatePythonDocstringUI() {
12851        var checked = !!pythonDocstrings.checked;
12852        document.getElementById("python-docstring-example").textContent = checked
12853          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
12854          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
12855        document.getElementById("python-docstring-live-help").textContent = checked
12856          ? "Enabled: docstrings contribute to comment-style totals."
12857          : "Disabled: docstrings are not counted as comment content.";
12858      }
12859
12860      function renderPresetChips(targetId, chips) {
12861        var target = document.getElementById(targetId);
12862        if (!target) return;
12863        target.innerHTML = (chips || []).map(function (chip) {
12864          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
12865        }).join('');
12866      }
12867
12868      function updatePresetDescriptions() {
12869        var scanInfo = scanPresetInfo[scanPreset.value];
12870        var artifactInfo = artifactPresetInfo[artifactPreset.value];
12871        document.getElementById("scan-preset-description").textContent = scanInfo.description;
12872        document.getElementById("scan-preset-example").textContent = scanInfo.example;
12873        document.getElementById("scan-preset-note").textContent = scanInfo.note;
12874        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
12875        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
12876        renderPresetChips("scan-preset-summary", scanInfo.chips);
12877        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
12878      }
12879
12880      function applyScanPreset() {
12881        var info = scanPresetInfo[scanPreset.value];
12882        if (!info || !info.apply) return;
12883        mixedLinePolicy.value = info.apply.mixed;
12884        pythonDocstrings.checked = !!info.apply.docstrings;
12885        document.getElementById("generated_file_detection").value = info.apply.generated;
12886        document.getElementById("minified_file_detection").value = info.apply.minified;
12887        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
12888        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
12889        document.getElementById("binary_file_behavior").value = info.apply.binary;
12890        updateMixedPolicyUI();
12891        updatePythonDocstringUI();
12892      }
12893
12894      function applyArtifactPreset() {
12895        var enabled = { html: false, pdf: false };
12896        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
12897        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
12898        if (artifactPreset.value === "html_only") { enabled.html = true; }
12899        if (artifactPreset.value === "machine") { enabled.html = true; }
12900
12901        artifactCards.forEach(function (card) {
12902          var artifact = card.getAttribute("data-artifact");
12903          if (artifact === "json") return;
12904          var checked = !!enabled[artifact];
12905          var checkbox = card.querySelector(".artifact-checkbox");
12906          checkbox.checked = checked;
12907          card.classList.toggle("selected", checked);
12908        });
12909      }
12910
12911      function toggleArtifactCard(card) {
12912        var checkbox = card.querySelector(".artifact-checkbox");
12913        checkbox.checked = !checkbox.checked;
12914        card.classList.toggle("selected", checkbox.checked);
12915      }
12916
12917      function updateReview() {
12918        var scanSummary = document.getElementById("review-scan-summary");
12919        var countSummary = document.getElementById("review-count-summary");
12920        var artifactSummary = document.getElementById("review-artifact-summary");
12921        var outputSummary = document.getElementById("review-output-summary");
12922        var previewSummary = document.getElementById("review-preview-summary");
12923        var readinessSummary = document.getElementById("review-readiness-summary");
12924        var includeText = document.getElementById("include_globs").value.trim();
12925        var excludeText = document.getElementById("exclude_globs").value.trim();
12926        var sidePathPreview = document.getElementById("side-path-preview");
12927        var sideOutputPreview = document.getElementById("side-output-preview");
12928        var sideTitlePreview = document.getElementById("side-title-preview");
12929
12930        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
12931        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
12932        if (sideTitlePreview) {
12933          var rt = document.getElementById("report_title");
12934          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
12935        }
12936
12937        scanSummary.innerHTML = ""
12938          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
12939          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
12940          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
12941
12942        countSummary.innerHTML = ""
12943          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
12944          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
12945          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
12946          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
12947          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
12948          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
12949          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
12950          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
12951
12952        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
12953        artifactSummary.innerHTML = ""
12954          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
12955          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
12956
12957        outputSummary.innerHTML = ""
12958          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
12959          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
12960
12961        if (previewSummary) {
12962          if (GIT_MODE) {
12963            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>';
12964          } else {
12965          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
12966          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
12967          var statMap = {};
12968          statButtons.forEach(function (button) {
12969            var valueNode = button.querySelector('.scope-stat-value');
12970            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
12971          });
12972          previewSummary.innerHTML = ''
12973            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
12974            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
12975            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
12976            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
12977            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
12978            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
12979
12980          if (readinessSummary) {
12981            var selectedArtifactsCount = selectedArtifacts.length;
12982            readinessSummary.innerHTML = ''
12983              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
12984              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
12985              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
12986              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
12987          }
12988          } // end else (non-GIT_MODE)
12989        }
12990      }
12991
12992      function escapeHtml(value) {
12993        return String(value)
12994          .replace(/&/g, "&amp;")
12995          .replace(/</g, "&lt;")
12996          .replace(/>/g, "&gt;")
12997          .replace(/"/g, "&quot;")
12998          .replace(/'/g, "&#39;");
12999      }
13000
13001      function isPythonVisible() {
13002        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
13003      }
13004
13005      function syncPythonVisibility() {
13006        var html = previewPanel.textContent || "";
13007        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
13008        pythonWraps.forEach(function (node) {
13009          node.classList.toggle("hidden", !hasPython);
13010        });
13011      }
13012
13013      function attachPreviewInteractions() {
13014        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
13015        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
13016        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
13017        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
13018        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
13019        var searchInput = previewPanel.querySelector("#explorer-search");
13020        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
13021        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
13022        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
13023        var activeFilter = "all";
13024        var activeLanguage = "";
13025        var searchTerm = "";
13026        var currentSortKey = null;
13027        var currentSortOrder = "asc";
13028        var childRows = {};
13029
13030        rows.forEach(function (row) {
13031          var parentId = row.getAttribute("data-parent-id") || "";
13032          var rowId = row.getAttribute("data-row-id") || "";
13033          if (!childRows[parentId]) childRows[parentId] = [];
13034          childRows[parentId].push(rowId);
13035        });
13036
13037        function rowById(id) {
13038          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
13039        }
13040
13041        function hasCollapsedAncestor(row) {
13042          var parentId = row.getAttribute("data-parent-id");
13043          while (parentId) {
13044            var parent = rowById(parentId);
13045            if (!parent) break;
13046            if (parent.getAttribute("data-expanded") === "false") return true;
13047            parentId = parent.getAttribute("data-parent-id");
13048          }
13049          return false;
13050        }
13051
13052        function updateToggleGlyph(row) {
13053          var toggle = row.querySelector(".tree-toggle");
13054          if (!toggle) return;
13055          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
13056        }
13057
13058        function rowSortValue(row, key) {
13059          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
13060        }
13061
13062        function updateSortButtons() {
13063          sortButtons.forEach(function (button) {
13064            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
13065            var indicator = button.querySelector(".tree-sort-indicator");
13066            button.classList.toggle("active", isActive);
13067            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
13068            if (indicator) {
13069              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
13070            }
13071          });
13072        }
13073
13074        function sortSiblingRows() {
13075          if (!treeContainer) {
13076            updateSortButtons();
13077            return;
13078          }
13079
13080          var rowMap = {};
13081          var childrenMap = {};
13082          rows.forEach(function (row) {
13083            var rowId = row.getAttribute("data-row-id");
13084            var parentId = row.getAttribute("data-parent-id") || "";
13085            rowMap[rowId] = row;
13086            if (!childrenMap[parentId]) childrenMap[parentId] = [];
13087            childrenMap[parentId].push(rowId);
13088          });
13089
13090          Object.keys(childrenMap).forEach(function (parentId) {
13091            if (!parentId) return;
13092            childrenMap[parentId].sort(function (a, b) {
13093              var rowA = rowMap[a];
13094              var rowB = rowMap[b];
13095              if (!currentSortKey) {
13096                return Number(a) - Number(b);
13097              }
13098              var valueA = rowSortValue(rowA, currentSortKey);
13099              var valueB = rowSortValue(rowB, currentSortKey);
13100              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
13101              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
13102              var fallbackA = rowSortValue(rowA, "name");
13103              var fallbackB = rowSortValue(rowB, "name");
13104              if (fallbackA < fallbackB) return -1;
13105              if (fallbackA > fallbackB) return 1;
13106              return Number(a) - Number(b);
13107            });
13108          });
13109
13110          var orderedIds = [];
13111          function pushChildren(parentId) {
13112            (childrenMap[parentId] || []).forEach(function (childId) {
13113              orderedIds.push(childId);
13114              pushChildren(childId);
13115            });
13116          }
13117
13118          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
13119            orderedIds.push(topId);
13120            pushChildren(topId);
13121          });
13122
13123          orderedIds.forEach(function (id) {
13124            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
13125          });
13126          updateSortButtons();
13127        }
13128
13129        function updateLanguageButtons() {
13130          languageButtons.forEach(function (button) {
13131            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
13132            var isActive = languageValue === activeLanguage;
13133            button.classList.toggle("active", isActive);
13134          });
13135        }
13136
13137        function rowSelfMatches(row) {
13138          var kind = row.getAttribute("data-kind");
13139          var status = row.getAttribute("data-status");
13140          var language = (row.getAttribute("data-language") || "").toLowerCase();
13141          var name = row.getAttribute("data-name-lower") || "";
13142          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
13143          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
13144          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
13145          var passesLanguage = !activeLanguage || language === activeLanguage;
13146          return passesFilter && passesSearch && passesLanguage;
13147        }
13148
13149        function hasMatchingDescendant(rowId) {
13150          return (childRows[rowId] || []).some(function (childId) {
13151            var childRow = rowById(childId);
13152            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
13153          });
13154        }
13155
13156        function rowMatches(row) {
13157          if (rowSelfMatches(row)) return true;
13158          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
13159        }
13160
13161        function resetViewState() {
13162          activeFilter = "all";
13163          activeLanguage = "";
13164          searchTerm = "";
13165          currentSortKey = null;
13166          currentSortOrder = "asc";
13167          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13168          if (searchInput) searchInput.value = "";
13169          if (filterSelect) filterSelect.value = "all";
13170          updateLanguageButtons();
13171        }
13172
13173        function applyVisibility() {
13174          rows.forEach(function (row) {
13175            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
13176            row.classList.toggle("hidden-by-filter", !visible);
13177            row.style.display = visible ? "grid" : "none";
13178          });
13179          buttons.forEach(function (button) {
13180            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
13181          });
13182          if (filterSelect) filterSelect.value = activeFilter;
13183        }
13184
13185        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
13186        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
13187        var originalStats = {};
13188        buttons.forEach(function (btn) {
13189          var f = btn.getAttribute('data-filter');
13190          var v = btn.querySelector('.scope-stat-value');
13191          if (f && v) originalStats[f] = v.textContent;
13192        });
13193
13194        function applySubmoduleStats(statsJson) {
13195          try {
13196            var s = JSON.parse(statsJson);
13197            buttons.forEach(function (btn) {
13198              var f = btn.getAttribute('data-filter');
13199              var v = btn.querySelector('.scope-stat-value');
13200              if (!v) return;
13201              if (f === 'dir') v.textContent = s.dirs;
13202              else if (f === 'file') v.textContent = s.files;
13203              else if (f === 'supported') v.textContent = s.supported;
13204              else if (f === 'skipped') v.textContent = s.skipped;
13205              else if (f === 'unsupported') v.textContent = s.unsupported;
13206            });
13207          } catch (e) {}
13208        }
13209
13210        function restoreBaseRepoStats() {
13211          buttons.forEach(function (btn) {
13212            var f = btn.getAttribute('data-filter');
13213            var v = btn.querySelector('.scope-stat-value');
13214            if (v && originalStats[f]) v.textContent = originalStats[f];
13215          });
13216          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
13217          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
13218        }
13219
13220        submoduleChips.forEach(function (chip) {
13221          chip.addEventListener('click', function () {
13222            var statsJson = chip.getAttribute('data-sub-stats');
13223            if (!statsJson) return;
13224            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
13225            chip.classList.add('active');
13226            applySubmoduleStats(statsJson);
13227            if (baseRepoBtn) baseRepoBtn.style.display = '';
13228          });
13229        });
13230
13231        if (baseRepoBtn) {
13232          baseRepoBtn.addEventListener('click', function () {
13233            restoreBaseRepoStats();
13234            resetViewState();
13235            sortSiblingRows();
13236            applyVisibility();
13237          });
13238        }
13239
13240        buttons.forEach(function (button) {
13241          button.addEventListener("click", function () {
13242            var filterValue = button.getAttribute("data-filter") || "all";
13243            if (filterValue === "reset-view") {
13244              restoreBaseRepoStats();
13245              resetViewState();
13246              sortSiblingRows();
13247              applyVisibility();
13248              return;
13249            }
13250            activeFilter = filterValue;
13251            applyVisibility();
13252          });
13253        });
13254
13255        rows.forEach(function (row) {
13256          updateToggleGlyph(row);
13257          var toggle = row.querySelector(".tree-toggle");
13258          if (toggle) {
13259            toggle.addEventListener("click", function () {
13260              var expanded = row.getAttribute("data-expanded") !== "false";
13261              row.setAttribute("data-expanded", expanded ? "false" : "true");
13262              updateToggleGlyph(row);
13263              applyVisibility();
13264            });
13265          }
13266        });
13267
13268        actionButtons.forEach(function (button) {
13269          button.addEventListener("click", function () {
13270            var action = button.getAttribute("data-explorer-action");
13271            if (action === "expand-all") {
13272              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13273            } else if (action === "collapse-all") {
13274              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
13275            } else if (action === "clear-filters") {
13276              resetViewState();
13277            }
13278            sortSiblingRows();
13279            applyVisibility();
13280          });
13281        });
13282
13283        if (filterSelect) {
13284          filterSelect.addEventListener("change", function () {
13285            activeFilter = filterSelect.value || "all";
13286            applyVisibility();
13287          });
13288        }
13289
13290        languageButtons.forEach(function (button) {
13291          button.addEventListener("click", function () {
13292            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
13293            updateLanguageButtons();
13294            applyVisibility();
13295          });
13296        });
13297
13298        sortButtons.forEach(function (button) {
13299          button.addEventListener("click", function () {
13300            var sortKey = button.getAttribute("data-sort-key");
13301            if (currentSortKey === sortKey) {
13302              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
13303            } else {
13304              currentSortKey = sortKey;
13305              currentSortOrder = "asc";
13306            }
13307            sortSiblingRows();
13308            applyVisibility();
13309          });
13310        });
13311
13312        if (searchInput) {
13313          searchInput.addEventListener("input", function () {
13314            searchTerm = searchInput.value.trim().toLowerCase();
13315            applyVisibility();
13316          });
13317        }
13318
13319        updateLanguageButtons();
13320        sortSiblingRows();
13321        applyVisibility();
13322      }
13323
13324      function loadPreview() {
13325        if (!previewPanel || !pathInput) return;
13326        if (GIT_MODE) {
13327          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>';
13328          return;
13329        }
13330        var path = pathInput.value.trim();
13331        var zeroWarn = document.getElementById('zero-files-warning');
13332        if (!path) {
13333          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
13334          if (zeroWarn) zeroWarn.style.display = 'none';
13335          return;
13336        }
13337        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
13338        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
13339        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
13340        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
13341        var _prevMsgs = [
13342          'Scanning directory structure…',
13343          'Detecting file types…',
13344          'Applying include / exclude filters…',
13345          'Estimating file counts…',
13346          'Building scope preview…',
13347          'Almost there…'
13348        ];
13349        var _prevMsgIdx = 0;
13350        var _prevStart = Date.now();
13351        previewPanel.innerHTML =
13352          '<div class="preview-loading">' +
13353          '<div class="preview-spinner"></div>' +
13354          '<div class="preview-loading-text">' +
13355          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
13356          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
13357          '</div></div>';
13358        var _sizeTextEl = document.getElementById('project-size-text');
13359        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
13360        window._previewInterval = setInterval(function() {
13361          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
13362          var ml = document.getElementById('plm');
13363          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
13364        }, 1500);
13365        window._previewElapsedTimer = setInterval(function() {
13366          var el = document.getElementById('ple');
13367          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
13368        }, 1000);
13369        var previewUrl = "/preview?path=" + encodeURIComponent(path)
13370          + "&include_globs=" + encodeURIComponent(includeValue)
13371          + "&exclude_globs=" + encodeURIComponent(excludeValue);
13372        fetch(previewUrl)
13373          .then(function (response) { return response.text(); })
13374          .then(function (html) {
13375            clearInterval(window._previewInterval); window._previewInterval = null;
13376            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
13377            previewPanel.innerHTML = html;
13378            attachPreviewInteractions();
13379            syncPythonVisibility();
13380            updateReview();
13381            setTimeout(collapseLanguagePills, 50);
13382            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
13383            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
13384            var sizeText = document.getElementById('project-size-text');
13385            var sizeBtn = document.getElementById('project-size-btn');
13386            // In server mode with upload sizes available, keep the compressed/original pair.
13387            if (SERVER_MODE && window._lastUploadSizes) {
13388              var us = window._lastUploadSizes;
13389              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
13390                ' · Compressed: ' + fmtBytes(us.compressed_bytes);
13391              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
13392                ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
13393            } else if (sizeText && projectSize) {
13394              sizeText.textContent = 'Project size: ' + projectSize;
13395              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
13396            } else if (sizeText) {
13397              sizeText.textContent = 'Project size: —';
13398            }
13399            if (zeroWarn) {
13400              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
13401              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
13402              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
13403              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
13404              if (supportedCount === 0 && fileCount > 0) {
13405                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).';
13406                zeroWarn.style.display = '';
13407              } else {
13408                zeroWarn.style.display = 'none';
13409              }
13410            }
13411          })
13412          .catch(function (err) {
13413            clearInterval(window._previewInterval); window._previewInterval = null;
13414            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
13415            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
13416          });
13417      }
13418
13419      function pickDirectory(targetInput, kind) {
13420        if (SERVER_MODE) {
13421          if (kind === 'output') {
13422            showBannerToast(
13423              'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
13424              false,
13425              { top: true, icon: '📁' }
13426            );
13427            return;
13428          }
13429          var inputEl = kind === 'coverage'
13430            ? document.getElementById('cov-upload-input')
13431            : document.getElementById('dir-upload-input');
13432          if (!inputEl) return;
13433          inputEl.onchange = function () {
13434            var files = inputEl.files;
13435            if (!files || files.length === 0) return;
13436            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
13437            if (browseBtn) browseBtn.disabled = true;
13438
13439            function fileToBase64(file) {
13440              return new Promise(function (resolve, reject) {
13441                var reader = new FileReader();
13442                reader.onload = function () {
13443                  var b64 = reader.result.split(',')[1];
13444                  resolve(b64);
13445                };
13446                reader.onerror = reject;
13447                reader.readAsDataURL(file);
13448              });
13449            }
13450
13451            if (kind === 'coverage') {
13452              var f = files[0];
13453              if (previewPanel && targetInput === pathInput)
13454                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
13455              fileToBase64(f).then(function (b64) {
13456                return fetch('/api/upload-file', {
13457                  method: 'POST',
13458                  headers: { 'Content-Type': 'application/json' },
13459                  body: JSON.stringify({ filename: f.name, content: b64 })
13460                }).then(function (r) { return r.json(); });
13461              })
13462                .then(function (d) {
13463                  if (d && d.tmp_path) {
13464                    if (coverageInput) coverageInput.value = d.tmp_path;
13465                    setCovStatus('idle');
13466                  } else if (d && d.error) { showBannerToast(d.error, true); }
13467                })
13468                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
13469                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
13470            } else {
13471              // ── Filter to source-code files only ─────────────────────────
13472              // Binary, generated, and dependency files (node_modules, .git,
13473              // build artifacts) are skipped so they are never uploaded.
13474              var CODE_EXTS = new Set([
13475                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13476                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13477                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13478                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13479                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13480                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
13481                'tf','hcl','proto','thrift','avsc','graphql','gql'
13482              ]);
13483              var codeFiles = [];
13484              for (var i = 0; i < files.length; i++) {
13485                var f = files[i];
13486                var name = f.name;
13487                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
13488                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
13489                  codeFiles.push(f); continue;
13490                }
13491                var dot = name.lastIndexOf('.');
13492                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
13493              }
13494              // Collect specific .git metadata files for server-side git detection.
13495              // These have no source extension so they are excluded by the loop above,
13496              // but the server needs them to read branch/commit/author without running git.
13497              var gitMetaFiles = [];
13498              for (var i = 0; i < files.length; i++) {
13499                var f = files[i];
13500                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
13501                var gitIdx = rp.indexOf('/.git/');
13502                if (gitIdx < 0) continue;
13503                var gitRel = rp.slice(gitIdx + 1);
13504                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
13505                    gitRel === '.git/logs/HEAD' ||
13506                    gitRel.startsWith('.git/refs/heads/') ||
13507                    gitRel.startsWith('.git/refs/tags/')) {
13508                  gitMetaFiles.push(f);
13509                }
13510              }
13511              var uploadFiles = codeFiles.concat(gitMetaFiles);
13512              var total = files.length;
13513              var kept = codeFiles.length;
13514              if (kept === 0) {
13515                if (previewPanel && targetInput === pathInput)
13516                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
13517                if (browseBtn) browseBtn.disabled = false;
13518                inputEl.value = '';
13519                return;
13520              }
13521
13522              // ── Helper: apply upload result to UI ────────────────────────
13523              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
13524              function applyUploadResult(tmpPath, sizes) {
13525                targetInput.value = tmpPath;
13526                scrollInputToEnd(targetInput);
13527                if (sizes && SERVER_MODE) {
13528                  window._lastUploadSizes = sizes;
13529                  // Immediately show both sizes before preview loads.
13530                  var sizeText = document.getElementById('project-size-text');
13531                  var sizeBtn = document.getElementById('project-size-btn');
13532                  if (sizeText) {
13533                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13534                      ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13535                  }
13536                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13537                    ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13538                }
13539                if (targetInput === pathInput) {
13540                  updateReportTitleFromPath();
13541                  autoSetOutputDir(tmpPath);
13542                  fetchProjectHistory(tmpPath);
13543                  loadPreview();
13544                  suggestCoverageFile(tmpPath);
13545                }
13546                updateReview();
13547                if (browseBtn) browseBtn.disabled = false;
13548                inputEl.value = '';
13549              }
13550
13551              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
13552              if (typeof CompressionStream !== 'undefined') {
13553                if (previewPanel && targetInput === pathInput)
13554                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13555
13556                // Build a minimal POSIX ustar tar header for a single file entry.
13557                function buildUstarHeader(filePath, fileSize) {
13558                  var BLOCK = 512;
13559                  var hdr = new Uint8Array(BLOCK);
13560                  var enc = new TextEncoder();
13561                  function wStr(off, len, s) {
13562                    var b = enc.encode(s);
13563                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
13564                  }
13565                  function wOct(off, len, val) {
13566                    var s = val.toString(8);
13567                    while (s.length < len - 1) s = '0' + s;
13568                    wStr(off, len, s + '\0');
13569                  }
13570                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
13571                  var name = filePath, prefix = '';
13572                  if (filePath.length > 99) {
13573                    var split = filePath.lastIndexOf('/', 154);
13574                    if (split > 0 && filePath.length - split - 1 <= 99) {
13575                      prefix = filePath.substring(0, split);
13576                      name   = filePath.substring(split + 1);
13577                    } else { name = filePath.substring(0, 99); }
13578                  }
13579                  wStr(0,   100, name);          // name
13580                  wOct(100,   8, 0o000644);      // mode
13581                  wOct(108,   8, 0);             // uid
13582                  wOct(116,   8, 0);             // gid
13583                  wOct(124,  12, fileSize);      // size
13584                  wOct(136,  12, 0);             // mtime (epoch)
13585                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
13586                  hdr[156] = 48;                 // type flag '0' = regular file
13587                  wStr(157, 100, '');            // linkname
13588                  wStr(257,   6, 'ustar');       // magic
13589                  wStr(263,   2, '00');          // version
13590                  wStr(265,  32, '');            // uname
13591                  wStr(297,  32, '');            // gname
13592                  wOct(329,   8, 0);             // devmajor
13593                  wOct(337,   8, 0);             // devminor
13594                  wStr(345, 155, prefix);        // prefix
13595                  // Compute checksum (sum of all bytes, placeholder = 32).
13596                  var chk = 0;
13597                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13598                  var cs = chk.toString(8);
13599                  while (cs.length < 6) cs = '0' + cs;
13600                  wStr(148, 8, cs + '\0 ');
13601                  return hdr;
13602                }
13603
13604                // Build tar.gz one file at a time, piping through CompressionStream.
13605                // RAM usage = compressed output buffer + one file at a time.
13606                (async function () {
13607                  try {
13608                    var BLOCK = 512;
13609                    var cs     = new CompressionStream('gzip');
13610                    var writer = cs.writable.getWriter();
13611                    var chunks = [];
13612                    var reader = cs.readable.getReader();
13613                    var collecting = (async function () {
13614                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
13615                    })();
13616
13617                    for (var i = 0; i < uploadFiles.length; i++) {
13618                      var file = uploadFiles[i];
13619                      var path = file.webkitRelativePath || file.name;
13620                      var buf  = await file.arrayBuffer();
13621                      var data = new Uint8Array(buf);
13622                      // Header block
13623                      await writer.write(buildUstarHeader(path, data.length));
13624                      // Data padded to 512-byte boundary
13625                      if (data.length > 0) {
13626                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
13627                        var block  = new Uint8Array(padded);
13628                        block.set(data);
13629                        await writer.write(block);
13630                      }
13631                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
13632                        if (previewPanel && targetInput === pathInput)
13633                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13634                      }
13635                    }
13636                    // End-of-archive: two 512-byte zero blocks
13637                    await writer.write(new Uint8Array(BLOCK * 2));
13638                    await writer.close();
13639                    await collecting;
13640
13641                    var blob = new Blob(chunks, { type: 'application/gzip' });
13642                    var sizeMB = (blob.size / 1048576).toFixed(1);
13643                    if (previewPanel && targetInput === pathInput)
13644                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
13645
13646                    var resp = await fetch('/api/upload-tarball', {
13647                      method: 'POST',
13648                      headers: { 'Content-Type': 'application/gzip' },
13649                      body: blob
13650                    });
13651                    var d = await resp.json();
13652                    if (d && d.tmp_path) {
13653                      applyUploadResult(d.tmp_path, {
13654                        compressed_bytes: d.compressed_bytes || 0,
13655                        original_bytes: d.original_bytes || 0
13656                      });
13657                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13658                  } catch (e) {
13659                    showBannerToast('Upload failed: ' + String(e), true);
13660                    if (browseBtn) browseBtn.disabled = false;
13661                    inputEl.value = '';
13662                  }
13663                })();
13664
13665              } else {
13666                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
13667                // Used only on browsers that lack CompressionStream (pre-2023).
13668                var BATCH = 200;
13669                var batches = [];
13670                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
13671                var totalBatches = batches.length;
13672                if (previewPanel && targetInput === pathInput)
13673                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
13674
13675                function sendBatch(idx, currentUploadId, lastTmpPath) {
13676                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
13677                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
13678                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
13679                  Promise.all(batches[idx].map(function (file) {
13680                    return fileToBase64(file).then(function (b64) {
13681                      return { path: file.webkitRelativePath || file.name, content: b64 };
13682                    });
13683                  })).then(function (fileList) {
13684                    var body = { files: fileList };
13685                    if (currentUploadId) body.upload_id = currentUploadId;
13686                    return fetch('/api/upload-directory', {
13687                      method: 'POST', headers: { 'Content-Type': 'application/json' },
13688                      body: JSON.stringify(body)
13689                    }).then(function (r) { return r.json(); });
13690                  }).then(function (d) {
13691                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
13692                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13693                  }).catch(function (e) {
13694                    showBannerToast('Upload failed: ' + String(e), true);
13695                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
13696                  });
13697                }
13698                sendBatch(0, null, '');
13699              }
13700            }
13701          };
13702          inputEl.click();
13703          return;
13704        }
13705
13706        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
13707        if (browseButton) browseButton.disabled = true;
13708
13709        if (previewPanel && targetInput === pathInput) {
13710          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
13711        }
13712
13713        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
13714          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
13715          .then(function (data) {
13716            if (data && data.selected_path) {
13717              targetInput.value = data.selected_path;
13718              scrollInputToEnd(targetInput);
13719
13720              if (targetInput === pathInput) {
13721                updateReportTitleFromPath();
13722                autoSetOutputDir(data.selected_path);
13723                fetchProjectHistory(data.selected_path);
13724                loadPreview();
13725                suggestCoverageFile(data.selected_path);
13726              }
13727
13728              updateReview();
13729            } else if (targetInput === pathInput) {
13730              loadPreview();
13731            }
13732          })
13733          .catch(function () {
13734            window.alert("Directory picker request failed.");
13735            if (previewPanel && targetInput === pathInput) {
13736              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
13737            }
13738          })
13739          .finally(function () {
13740            if (browseButton) browseButton.disabled = false;
13741          });
13742      }
13743
13744      if (themeToggle) {
13745        themeToggle.addEventListener("click", function () {
13746          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
13747          applyTheme(nextTheme);
13748          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
13749        });
13750      }
13751
13752      stepButtons.forEach(function (button) {
13753        button.addEventListener("click", function () {
13754          setStep(Number(button.getAttribute("data-step-target")));
13755        });
13756      });
13757
13758      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
13759        button.addEventListener("click", function () {
13760          setStep(Number(button.getAttribute("data-step-target")) || 1);
13761        });
13762      });
13763
13764      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
13765        button.addEventListener("click", function () {
13766          updateReview();
13767          setStep(Number(button.getAttribute("data-next")));
13768        });
13769      });
13770
13771      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
13772        button.addEventListener("click", function () {
13773          setStep(Number(button.getAttribute("data-prev")));
13774        });
13775      });
13776
13777      document.addEventListener("keydown", function (e) {
13778        var tag = (document.activeElement || {}).tagName || "";
13779        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
13780        if (e.altKey || e.ctrlKey || e.metaKey) return;
13781        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
13782        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
13783      });
13784
13785      if (useSamplePath) {
13786        useSamplePath.addEventListener("click", function () {
13787          pathInput.value = "tests/fixtures/basic";
13788          updateReportTitleFromPath();
13789          autoSetOutputDir("tests/fixtures/basic");
13790          loadPreview();
13791          suggestCoverageFile("tests/fixtures/basic");
13792        });
13793      }
13794
13795      if (useDefaultOutput) {
13796        useDefaultOutput.addEventListener("click", function () {
13797          delete outputDirInput.dataset.userEdited;
13798          autoSetOutputDir(pathInput ? pathInput.value : "");
13799          updateReview();
13800        });
13801      }
13802
13803      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
13804      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
13805
13806      // ── Drag-and-drop directory upload (server mode only) ─────────────────
13807      // Dropping a folder onto the path field bypasses Chrome's
13808      // "Upload X files to this site?" confirmation dialog.
13809      async function readDirRecursively(dirEntry, basePath) {
13810        var reader = dirEntry.createReader();
13811        var all = [];
13812        for (;;) {
13813          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
13814          if (!batch.length) break;
13815          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
13816        }
13817        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
13818        var out = [];
13819        for (var i = 0; i < all.length; i++) {
13820          var sub = all[i];
13821          if (sub.isFile) {
13822            var f = await new Promise(function(res) { sub.file(res); });
13823            out.push({ file: f, path: basePath + '/' + sub.name });
13824          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
13825            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
13826            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
13827          }
13828        }
13829        return out;
13830      }
13831
13832      function setupPathDropZone() {
13833        if (!SERVER_MODE || !pathInput) return;
13834        var CODE_EXTS = new Set([
13835          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13836          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13837          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13838          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13839          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13840          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
13841        ]);
13842        pathInput.addEventListener('dragover', function(e) {
13843          e.preventDefault();
13844          pathInput.classList.add('drag-over');
13845        });
13846        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
13847        pathInput.addEventListener('drop', function(e) {
13848          e.preventDefault();
13849          pathInput.classList.remove('drag-over');
13850          var items = e.dataTransfer.items;
13851          if (!items || !items.length) return;
13852          var dirEntry = null;
13853          for (var i = 0; i < items.length; i++) {
13854            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
13855            if (entry && entry.isDirectory) { dirEntry = entry; break; }
13856          }
13857          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
13858          var btn = browsePath;
13859          if (btn) btn.disabled = true;
13860          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
13861
13862          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
13863            var total = allEntries.length;
13864            var codeEntries = allEntries.filter(function(e) {
13865              var n = e.file.name;
13866              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
13867              var dot = n.lastIndexOf('.');
13868              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
13869            });
13870            var kept = codeEntries.length;
13871            if (kept === 0) {
13872              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
13873              if (btn) btn.disabled = false; return;
13874            }
13875
13876            function finish(tmpPath, sizes) {
13877              pathInput.value = tmpPath;
13878              scrollInputToEnd(pathInput);
13879              if (sizes) {
13880                window._lastUploadSizes = sizes;
13881                var sizeText = document.getElementById('project-size-text');
13882                var sizeBtn = document.getElementById('project-size-btn');
13883                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13884                  ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13885                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13886                  ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13887              }
13888              updateReportTitleFromPath();
13889              autoSetOutputDir(tmpPath);
13890              fetchProjectHistory(tmpPath);
13891              loadPreview();
13892              suggestCoverageFile(tmpPath);
13893              updateReview();
13894              if (btn) btn.disabled = false;
13895            }
13896
13897            if (typeof CompressionStream === 'undefined') {
13898              showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
13899              if (btn) btn.disabled = false; return;
13900            }
13901
13902            try {
13903              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13904              var BLOCK = 512;
13905              var cs = new CompressionStream('gzip');
13906              var wtr = cs.writable.getWriter();
13907              var chunks = [];
13908              var rdr = cs.readable.getReader();
13909              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
13910
13911              function buildHdr(fp, sz) {
13912                var hdr = new Uint8Array(BLOCK);
13913                var enc = new TextEncoder();
13914                function wS(o, l, s) { var b = enc.encode(s); for (var i = 0; i < Math.min(b.length, l); i++) hdr[o + i] = b[i]; }
13915                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
13916                var nm = fp, pfx = '';
13917                if (fp.length > 99) { var sp = fp.lastIndexOf('/', 154); if (sp > 0 && fp.length - sp - 1 <= 99) { pfx = fp.substring(0, sp); nm = fp.substring(sp + 1); } else { nm = fp.substring(0, 99); } }
13918                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
13919                for (var i = 148; i < 156; i++) hdr[i] = 32;
13920                hdr[156] = 48; wS(157,100,''); wS(257,6,'ustar'); wS(263,2,'00'); wS(265,32,''); wS(297,32,''); wO(329,8,0); wO(337,8,0); wS(345,155,pfx);
13921                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13922                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
13923                return hdr;
13924              }
13925
13926              for (var i = 0; i < codeEntries.length; i++) {
13927                var ce = codeEntries[i];
13928                var buf = await ce.file.arrayBuffer();
13929                var data = new Uint8Array(buf);
13930                await wtr.write(buildHdr(ce.path, data.length));
13931                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
13932                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
13933                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13934              }
13935              await wtr.write(new Uint8Array(BLOCK * 2));
13936              await wtr.close();
13937              await collecting;
13938
13939              var blob = new Blob(chunks, { type: 'application/gzip' });
13940              var sizeMB = (blob.size / 1048576).toFixed(1);
13941              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
13942              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
13943              var d = await resp.json();
13944              if (d && d.tmp_path) {
13945                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
13946              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
13947            } catch (err) {
13948              showBannerToast('Upload failed: ' + String(err), true);
13949              if (btn) btn.disabled = false;
13950            }
13951          }).catch(function(err) {
13952            showBannerToast('Could not read folder: ' + String(err), true);
13953            if (btn) btn.disabled = false;
13954          });
13955        });
13956      }
13957      setupPathDropZone();
13958      if (browseCoverage) {
13959        browseCoverage.addEventListener("click", function () {
13960          pickDirectory(coverageInput || pathInput, "coverage");
13961        });
13962      }
13963
13964      function setCovStatus(state, opts) {
13965        if (!covScanStatus) return;
13966        opts = opts || {};
13967        covScanStatus.className = "cov-scan-status cov-scan-" + state;
13968        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
13969        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>';
13970        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>';
13971        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>';
13972        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>';
13973        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
13974        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
13975        if (state === "scanning") {
13976          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
13977        } else if (state === "found") {
13978          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13979          html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
13980          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
13981          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
13982        } else if (state === "hint") {
13983          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13984          html += '<div class="cov-scan-title">' + tb2 + ' detected &mdash; no coverage file found yet</div>';
13985          html += '<div class="cov-scan-sub">Generate one with:</div>';
13986          html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
13987        } else if (state === "none") {
13988          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
13989          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
13990        }
13991        html += '</div></div>';
13992        covScanStatus.innerHTML = html;
13993        if (state === "found") {
13994          var useBtn = covScanStatus.querySelector(".cov-scan-use");
13995          if (useBtn) useBtn.addEventListener("click", function () {
13996            if (coverageInput) coverageInput.value = "";
13997            covAutoFilled = false;
13998            setCovStatus("idle");
13999          });
14000        }
14001      }
14002
14003      function suggestCoverageFile(projectPath) {
14004        if (!coverageInput || !covScanStatus) return;
14005        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
14006        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
14007        clearTimeout(coverageSuggestTimer);
14008        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
14009        setCovStatus("scanning");
14010        coverageSuggestTimer = setTimeout(function () {
14011          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
14012            .then(function (r) { return r.json(); })
14013            .then(function (d) {
14014              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
14015              if (!d) { setCovStatus("none"); return; }
14016              if (d.found) {
14017                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
14018                setCovStatus("found", { found: d.found, tool: d.tool });
14019              } else if (d.tool && d.hint) {
14020                setCovStatus("hint", { tool: d.tool, hint: d.hint });
14021              } else {
14022                setCovStatus("none");
14023              }
14024            })
14025            .catch(function () { setCovStatus("idle"); });
14026        }, 600);
14027      }
14028
14029      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
14030
14031      if (coverageInput) coverageInput.addEventListener("input", function () {
14032        covAutoFilled = false;
14033        if (!this.value.trim()) setCovStatus("idle");
14034      });
14035
14036      // ── Language pill overflow: collapse to "+N more" chip ─────────────
14037      function collapseLanguagePills() {
14038        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
14039        rows.forEach(function(row) {
14040          // Remove any previous overflow chip
14041          var prev = row.querySelector('.lang-overflow-chip');
14042          if (prev) prev.remove();
14043          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
14044          pills.forEach(function(p) { p.style.display = ''; });
14045          if (!pills.length) return;
14046
14047          // Measure after restoring all pills
14048          var containerRight = row.getBoundingClientRect().right;
14049          var hidden = [];
14050          for (var i = pills.length - 1; i >= 1; i--) {
14051            var rect = pills[i].getBoundingClientRect();
14052            if (rect.right > containerRight + 2) {
14053              hidden.unshift(pills[i]);
14054              pills[i].style.display = 'none';
14055            } else {
14056              break;
14057            }
14058          }
14059
14060          if (hidden.length) {
14061            var chip = document.createElement('button');
14062            chip.type = 'button';
14063            chip.className = 'language-pill lang-overflow-chip';
14064            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
14065            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
14066            row.appendChild(chip);
14067          }
14068        });
14069      }
14070
14071      // Run after preview loads (preview panel populates language pills)
14072      var _origLoadPreviewCb = window.__previewLoaded;
14073      document.addEventListener('previewLoaded', collapseLanguagePills);
14074      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
14075      setTimeout(collapseLanguagePills, 400);
14076
14077      // ── Project history & output dir auto-set ──────────────────────────
14078      var wsOutputRoot   = document.getElementById("ws-output-root");
14079      var wsScanCount    = document.getElementById("ws-scan-count");
14080      var wsLastScan     = document.getElementById("ws-last-scan");
14081      var historyBadge   = document.getElementById("path-history-badge");
14082      var historyTimer   = null;
14083
14084      var wsOutputLink = document.getElementById("ws-output-link");
14085      function syncStripOutputRoot() {
14086        var val = outputDirInput ? outputDirInput.value : "";
14087        var display = val || "project/sloc";
14088        if (wsOutputRoot) wsOutputRoot.textContent = display;
14089        if (wsOutputLink) wsOutputLink.dataset.folder = val;
14090      }
14091
14092      function scrollInputToEnd(input) {
14093        if (!input) return;
14094        // Defer so the DOM has the new value before we measure scroll width.
14095        requestAnimationFrame(function () {
14096          input.scrollLeft = input.scrollWidth;
14097          input.selectionStart = input.selectionEnd = input.value.length;
14098        });
14099      }
14100
14101      function autoSetOutputDir(projectPath) {
14102        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
14103        if (GIT_MODE && GIT_OUTPUT_DIR) {
14104          outputDirInput.value = GIT_OUTPUT_DIR;
14105          scrollInputToEnd(outputDirInput);
14106          syncStripOutputRoot();
14107          updateReview();
14108          return;
14109        }
14110        if (!projectPath || !projectPath.trim()) return;
14111        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
14112        outputDirInput.value = cleaned + "/sloc";
14113        scrollInputToEnd(outputDirInput);
14114        syncStripOutputRoot();
14115        updateReview();
14116      }
14117
14118      var wsBranch = document.getElementById("ws-branch");
14119
14120      function fetchProjectHistory(projectPath) {
14121        if (!projectPath || !projectPath.trim()) {
14122          if (wsScanCount) wsScanCount.textContent = "—";
14123          if (wsLastScan)  wsLastScan.textContent  = "—";
14124          if (wsBranch)    wsBranch.textContent    = "—";
14125          if (historyBadge) historyBadge.style.display = "none";
14126          return;
14127        }
14128        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
14129          .then(function (r) { return r.ok ? r.json() : null; })
14130          .then(function (data) {
14131            if (!data) return;
14132            var countStr = data.scan_count > 0
14133              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
14134              : "never";
14135            var tsStr = data.last_scan_timestamp
14136              ? data.last_scan_timestamp.replace(" UTC","")
14137              : "—";
14138            if (wsScanCount) wsScanCount.textContent = countStr;
14139            if (wsLastScan)  wsLastScan.textContent  = tsStr;
14140            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
14141            if (data.scan_count > 0) {
14142              if (historyBadge) {
14143                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
14144                historyBadge.textContent = data.scan_count + " previous scan" +
14145                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
14146                  "Last: " + (data.last_scan_timestamp || "—") +
14147                  " — " + (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.";
14148                historyBadge.className = "path-history-badge found";
14149                historyBadge.style.display = "";
14150              }
14151            } else {
14152              if (historyBadge) historyBadge.style.display = "none";
14153            }
14154          })
14155          .catch(function () {});
14156      }
14157
14158      function onPathChange() {
14159        var val = pathInput ? pathInput.value : "";
14160        // Discard stale upload sizes when the user edits the path manually.
14161        window._lastUploadSizes = null;
14162        updateReportTitleFromPath();
14163        autoSetOutputDir(val);
14164        updateSidebarSummary();
14165        clearTimeout(historyTimer);
14166        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
14167        if (previewTimer) clearTimeout(previewTimer);
14168        previewTimer = setTimeout(loadPreview, 280);
14169        suggestCoverageFile(val);
14170      }
14171
14172      if (pathInput) {
14173        pathInput.addEventListener("input", onPathChange);
14174      }
14175
14176      if (outputDirInput) {
14177        outputDirInput.addEventListener("input", function () {
14178          outputDirInput.dataset.userEdited = "1";
14179          syncStripOutputRoot();
14180          updateReview();
14181        });
14182      }
14183
14184      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
14185        if (!node) return;
14186        node.addEventListener("input", function () {
14187          updateReview();
14188          if (previewTimer) clearTimeout(previewTimer);
14189          previewTimer = setTimeout(loadPreview, 280);
14190        });
14191      });
14192
14193      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
14194        var node = document.getElementById(id);
14195        if (node) node.addEventListener("change", updateReview);
14196      });
14197
14198      if (reportTitleInput) {
14199        reportTitleInput.addEventListener("input", function () {
14200          reportTitleTouched = reportTitleInput.value.trim().length > 0;
14201          updateReportTitleFromPath();
14202          updateReview();
14203        });
14204      }
14205
14206      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
14207      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
14208      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
14209      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
14210
14211      artifactCards.forEach(function (card) {
14212        card.addEventListener("click", function () {
14213          if (card.classList.contains("artifact-locked")) return;
14214          toggleArtifactCard(card);
14215          updateReview();
14216        });
14217      });
14218
14219      if (coverageInput) {
14220        coverageInput.addEventListener("input", function () {
14221          if (coverageInput.value.trim()) setCovStatus("idle");
14222        });
14223      }
14224
14225      if (form && loading && submitButton) {
14226        form.addEventListener("submit", function (e) {
14227          e.preventDefault();
14228          submitButton.disabled = true;
14229          submitButton.textContent = "Scanning...";
14230          startAsyncAnalysis(new FormData(form));
14231        });
14232      }
14233
14234      function openPath(folder) {
14235        if (!folder) return;
14236        fetch('/open-path?path=' + encodeURIComponent(folder))
14237          .then(function (r) { return r.json(); })
14238          .then(function (d) {
14239            if (d && d.server_mode_disabled)
14240              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
14241          })
14242          .catch(function () {});
14243      }
14244
14245      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14246        btn.addEventListener('click', function () {
14247          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
14248        });
14249      });
14250
14251      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
14252      if (wsOutputLink) {
14253        wsOutputLink.addEventListener('click', function () {
14254          openPath(wsOutputLink.dataset.folder || '');
14255        });
14256      }
14257
14258      loadSavedTheme();
14259      updateMixedPolicyUI();
14260      updatePythonDocstringUI();
14261      applyScanPreset();
14262      updatePresetDescriptions();
14263      applyArtifactPreset();
14264      updateReview();
14265      updateScrollProgress(); // initialise bar to 0% (step 1)
14266      window.addEventListener("scroll", updateScrollProgress, { passive: true });
14267      onPathChange();         // seed output dir, history badge, and preview from initial path
14268      loadPreview();
14269      updateStepNav(1);
14270
14271      // Restore step from URL hash on initial load (e.g., back-forward cache)
14272      (function() {
14273        var hashMatch = location.hash.match(/^#step([1-4])$/);
14274        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
14275      })();
14276
14277      (function randomizeWatermarks() {
14278        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14279        if (!wms.length) return;
14280        var placed = [];
14281        function tooClose(top, left) {
14282          for (var i = 0; i < placed.length; i++) {
14283            var dt = Math.abs(placed[i][0] - top);
14284            var dl = Math.abs(placed[i][1] - left);
14285            if (dt < 16 && dl < 12) return true;
14286          }
14287          return false;
14288        }
14289        function pick(leftBand) {
14290          for (var attempt = 0; attempt < 50; attempt++) {
14291            var top = Math.random() * 88 + 2;
14292            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14293            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14294          }
14295          var top = Math.random() * 88 + 2;
14296          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14297          placed.push([top, left]);
14298          return [top, left];
14299        }
14300        var half = Math.floor(wms.length / 2);
14301        wms.forEach(function (img, i) {
14302          var pos = pick(i < half);
14303          var size = Math.floor(Math.random() * 80 + 110);
14304          var rot = (Math.random() * 360).toFixed(1);
14305          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
14306          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;
14307        });
14308      })();
14309
14310      (function spawnCodeParticles() {
14311        var container = document.getElementById('code-particles');
14312        if (!container) return;
14313        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'];
14314        for (var i = 0; i < 38; i++) {
14315          (function(idx) {
14316            var el = document.createElement('span');
14317            el.className = 'code-particle';
14318            el.textContent = snippets[idx % snippets.length];
14319            var left = Math.random() * 94 + 2;
14320            var top = Math.random() * 88 + 6;
14321            var dur = (Math.random() * 10 + 9).toFixed(1);
14322            var delay = (Math.random() * 18).toFixed(1);
14323            var rot = (Math.random() * 26 - 13).toFixed(1);
14324            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14325            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';
14326            container.appendChild(el);
14327          })(i);
14328        }
14329      })();
14330    })();
14331  </script>
14332  <script nonce="{{ csp_nonce }}">
14333    (function () {
14334      var raw = {{ prefill_json|safe }};
14335      if (!raw || typeof raw !== 'object' || !raw.path) return;
14336      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output-dir') scrollInputToEnd(el); } }
14337      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
14338      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
14339      setVal('path-input', raw.path || '');
14340      setVal('include-globs', raw.include_globs || '');
14341      setVal('exclude-globs', raw.exclude_globs || '');
14342      setVal('output-dir', raw.output_dir || '');
14343      setVal('report-title', raw.report_title || '');
14344      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
14345      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
14346      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
14347      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
14348      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
14349      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
14350      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
14351      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
14352      setChecked('generate-html', raw.generate_html !== false);
14353      setChecked('generate-pdf', !!raw.generate_pdf);
14354      // Trigger dynamic UI updates after pre-fill.
14355      setTimeout(function () {
14356        var pathEl = document.getElementById('path-input');
14357        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
14358        var policyEl = document.getElementById('mixed-line-policy');
14359        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
14360      }, 80);
14361    })();
14362  </script>
14363  <script nonce="{{ csp_nonce }}">
14364  (function(){
14365    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'}];
14366    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);});}
14367    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14368    function init(){
14369      var btn=document.getElementById('settings-btn');if(!btn)return;
14370      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14371      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>';
14372      document.body.appendChild(m);
14373      var g=document.getElementById('scheme-grid');
14374      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);});
14375      var cl=document.getElementById('settings-close');
14376      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);
14377      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');});
14378      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14379      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14380    }
14381    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14382  }());
14383  </script>
14384  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
14385    <div class="wb-ftip-arrow"></div>
14386    <span id="wb-ftip-text"></span>
14387  </div>
14388  <script nonce="{{ csp_nonce }}">(function(){
14389    var tip=document.getElementById('wb-ftip');
14390    var txt=document.getElementById('wb-ftip-text');
14391    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
14392    if(!tip||!txt)return;
14393    function pos(el){
14394      var r=el.getBoundingClientRect();
14395      tip.style.display='block';
14396      var tw=tip.offsetWidth;
14397      var lx=r.left+r.width/2-tw/2;
14398      if(lx<8)lx=8;
14399      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
14400      tip.style.left=lx+'px';
14401      tip.style.top=(r.bottom+8)+'px';
14402      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';}
14403    }
14404    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
14405      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
14406      el.addEventListener('mouseleave',function(){tip.style.display='none';});
14407    });
14408  })();
14409  (function(){
14410    function fixArtifactHintSpacing(){
14411      var grid=document.querySelector('.artifact-grid');
14412      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
14413    }
14414    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
14415  }());
14416  (function(){
14417    var dot=document.getElementById('status-dot');
14418    var pingEl=document.getElementById('server-ping-ms');
14419    var tipEl=document.getElementById('server-tip-ping');
14420    var fm=document.getElementById('footer-mode');
14421    function setDotColor(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}
14422    function doPing(){
14423      var t0=performance.now();
14424      fetch('/healthz',{cache:'no-store'})
14425        .then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDotColor(ms);})
14426        .catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});
14427    }
14428    doPing();
14429    setInterval(doPing,5000);
14430    if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
14431  })();
14432  </script>
14433  <footer class="site-footer">
14434    local code analysis - metrics, history and reports
14435    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: {% if server_mode %}Network Server{% else %}Local{% endif %}</em>
14436    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14437    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14438    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14439    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14440  </footer>
14441</body>
14442</html>
14443"##,
14444    ext = "html"
14445)]
14446struct IndexTemplate {
14447    version: &'static str,
14448    prefill_json: String,
14449    csp_nonce: String,
14450    git_repo: String,
14451    git_ref: String,
14452    git_label_json: String,
14453    git_output_dir_json: String,
14454    server_mode: bool,
14455}
14456
14457// ── SplashTemplate ────────────────────────────────────────────────────────────
14458
14459#[derive(Template)]
14460#[template(
14461    source = r##"
14462<!doctype html>
14463<html lang="en">
14464<head>
14465  <meta charset="utf-8">
14466  <meta name="viewport" content="width=device-width, initial-scale=1">
14467  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
14468  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14469  <style nonce="{{ csp_nonce }}">
14470    :root {
14471      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14472      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14473      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14474      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14475      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14476    }
14477    body.dark-theme {
14478      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14479      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14480    }
14481    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
14482    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14483    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14484    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14485    .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;}
14486    @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));}}
14487    .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);}
14488    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14489    .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));}
14490    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14491    .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;}
14492    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14493    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14494    @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; } }
14495    .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;}
14496    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14497    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14498    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14499    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14500    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14501    .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;}
14502    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14503    .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);}
14504    .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;}
14505    .settings-close:hover{color:var(--text);background:var(--surface-2);}
14506    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14507    .settings-modal-body{padding:14px 16px 16px;}
14508    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14509    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14510    .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;}
14511    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14512    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14513    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14514    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14515    .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;}
14516    .tz-select:focus{border-color:var(--oxide);}
14517    .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;}
14518    .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;}
14519    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
14520    .hero{text-align:center;margin:0 auto 18px;}
14521    .hero-logo-wrap{display:inline-block;cursor:default;}
14522    .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;}
14523    .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;}
14524    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
14525    .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;}
14526    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%);}
14527    .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;
14528      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
14529      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
14530      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;}
14531    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
14532    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
14533    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;}
14534    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
14535    .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;}
14536    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
14537    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
14538    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
14539    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
14540    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
14541    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
14542    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
14543    .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;}
14544    .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;}
14545    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14546    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14547    .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);}
14548    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
14549    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
14550    .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);}
14551    .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);}
14552    .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);}
14553    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
14554    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
14555    .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;}
14556    body.dark-theme .action-card-cta{color:var(--oxide);}
14557    .action-card.view .action-card-cta{color:var(--accent-2);}
14558    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
14559    .action-card.compare .action-card-cta{color:#7c3aed;}
14560    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
14561    .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);}
14562    .action-card.git-tools .action-card-cta{color:#15803d;}
14563    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
14564    .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);}
14565    .action-card.trend .action-card-cta{color:#0e7490;}
14566    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
14567    .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);}
14568    .action-card.automation .action-card-cta{color:#b45309;}
14569    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
14570    .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);}
14571    .action-card.test-metrics .action-card-cta{color:#be185d;}
14572    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
14573    .action-card:hover .action-card-cta{gap:12px;}
14574    .action-card.card-split{flex-direction:row;align-items:stretch;}
14575    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
14576    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
14577    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
14578    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
14579    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
14580    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
14581    .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;}
14582    .ac-badge.active{opacity:1;}
14583    .ac-badge.github{border-color:#555;color:#555;}
14584    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
14585    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
14586    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
14587    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
14588    body.dark-theme .ac-right-row{color:var(--muted);}
14589    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
14590    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
14591    .divider{height:1px;background:var(--line);margin:32px 0;}
14592    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
14593    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
14594    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
14595    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
14596      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
14597    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14598    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
14599    body.dark-theme .info-chip-val{color:var(--oxide);}
14600    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
14601    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
14602      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
14603      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
14604    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
14605      border:6px solid transparent;border-top-color:var(--text);}
14606    .info-chip:hover .info-chip-tip{display:block;}
14607    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
14608    .chip-slide.fading{filter:blur(5px);opacity:0;}
14609    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14610    .site-footer a{color:var(--muted);}
14611    .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;}
14612    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
14613    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
14614    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
14615    .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;}
14616    .lan-badge.local{background:var(--oxide-2);}
14617    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
14618    .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);}
14619    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
14620    .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;}
14621    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
14622    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
14623    .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;}
14624    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
14625    .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;}
14626    .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);}
14627    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
14628    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
14629    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
14630    .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;}
14631    @media (max-height: 1100px) {
14632      .page{padding-top:10px;}
14633      .hero{margin-bottom:10px;}
14634      .hero-logo{width:54px;height:60px;}
14635      .hero-logo-shadow{width:42px;}
14636      .hero-title{font-size:28px;}
14637      .hero-subtitle{font-size:13px;}
14638      .card-sections{gap:16px;margin-bottom:10px;}
14639      .card-section-grid-2,.card-section-grid-3{gap:10px;}
14640      .action-card{padding:8px 15px 8px;}
14641      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
14642      .action-card-icon svg{width:18px;height:18px;}
14643      .action-card-title{font-size:13px;}
14644      .action-card-desc{font-size:11px;margin-bottom:6px;}
14645      .action-card-cta{font-size:11px;}
14646      .ac-right-row{font-size:11px;}
14647      .divider{margin:14px 0;}
14648      .info-strip{gap:7px;margin-bottom:12px;}
14649      .info-chip{padding:7px 10px;}
14650      .info-chip-val{font-size:13px;}
14651      .info-chip-label{font-size:9px;}
14652      .site-footer{padding:8px 24px;font-size:12px;}
14653    }
14654    @media (max-height: 850px) {
14655      .page{padding-top:6px;}
14656      .hero{margin-bottom:6px;}
14657      .hero-logo{width:42px;height:46px;}
14658      .hero-title{font-size:22px;}
14659      .hero-subtitle{font-size:12px;}
14660      .card-sections{gap:10px;}
14661      .action-card-desc{margin-bottom:4px;}
14662      .divider{margin:8px 0;}
14663      .info-strip{margin-bottom:6px;}
14664      .lan-local-hint{margin-top:10px;}
14665    }
14666  </style>
14667</head>
14668<body>
14669  <div class="background-watermarks" aria-hidden="true">
14670    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14671    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14672    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14673    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14674    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14675    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14676    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14677  </div>
14678  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14679  <div class="top-nav">
14680    <div class="top-nav-inner">
14681      <a class="brand" href="/">
14682        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14683        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14684      </a>
14685      <div class="nav-right">
14686        <a class="nav-pill" href="/">Home</a>
14687        <div class="nav-dropdown">
14688          <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>
14689          <div class="nav-dropdown-menu">
14690            <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>
14691          </div>
14692        </div>
14693        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14694        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14695        <div class="nav-dropdown">
14696          <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>
14697          <div class="nav-dropdown-menu">
14698            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
14699          </div>
14700        </div>
14701        <div class="server-status-wrap" id="server-status-wrap">
14702          <div class="nav-pill server-online-pill" id="server-status-pill">
14703            <span class="status-dot" id="status-dot"></span>
14704            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
14705            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14706          </div>
14707          <div class="server-status-tip">
14708            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
14709            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14710          </div>
14711        </div>
14712        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14713          <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>
14714        </button>
14715        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14716          <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>
14717          <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>
14718        </button>
14719      </div>
14720    </div>
14721  </div>
14722
14723  <div class="page">
14724    <div class="hero">
14725      <div class="hero-logo-wrap" id="hero-logo-wrap">
14726        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
14727      </div>
14728      <div class="hero-logo-shadow"></div>
14729      <div class="hero-title-wrap">
14730        <div class="hero-title-aura" aria-hidden="true"></div>
14731        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
14732      </div>
14733      <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>
14734    </div>
14735
14736    <div class="card-sections">
14737
14738      <div>
14739        <div class="card-section-label">Analysis</div>
14740        <div class="card-section-grid-2">
14741          <a class="action-card scan card-split" href="/scan-setup">
14742            <div class="action-card-left">
14743              <div class="action-card-icon">
14744                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
14745              </div>
14746              <div class="action-card-title">Scan Project</div>
14747              <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>
14748              <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>
14749            </div>
14750            <div class="action-card-sep"></div>
14751            <div class="action-card-right">
14752              <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>
14753              <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>
14754              <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>
14755              <div class="ac-right-stat" id="acp-scan-stat"></div>
14756            </div>
14757          </a>
14758          <a class="action-card test-metrics card-split" href="/test-metrics">
14759            <div class="action-card-left">
14760              <div class="action-card-icon">
14761                <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>
14762              </div>
14763              <div class="action-card-title">Test Metrics</div>
14764              <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>
14765              <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>
14766            </div>
14767            <div class="action-card-sep"></div>
14768            <div class="action-card-right">
14769              <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>
14770              <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>
14771              <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>
14772              <div class="ac-right-stat" id="acp-test-stat"></div>
14773            </div>
14774          </a>
14775        </div>
14776      </div>
14777
14778      <div>
14779        <div class="card-section-label">Reports &amp; Insights</div>
14780        <div class="card-section-grid-3">
14781          <a class="action-card view" href="/view-reports">
14782            <div class="action-card-icon">
14783              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
14784            </div>
14785            <div class="action-card-title">View Reports</div>
14786            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
14787            <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>
14788          </a>
14789          <a class="action-card compare" href="/compare-scans">
14790            <div class="action-card-icon">
14791              <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>
14792            </div>
14793            <div class="action-card-title">Compare Scans</div>
14794            <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>
14795            <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>
14796          </a>
14797          <a class="action-card trend" href="/trend-reports">
14798            <div class="action-card-icon">
14799              <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>
14800            </div>
14801            <div class="action-card-title">Trend Report</div>
14802            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
14803            <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>
14804          </a>
14805        </div>
14806      </div>
14807
14808      <div>
14809        <div class="card-section-label">Developer Tools</div>
14810        <div class="card-section-grid-2">
14811          <a class="action-card git-tools card-split" href="/git-browser">
14812            <div class="action-card-left">
14813              <div class="action-card-icon">
14814                <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>
14815              </div>
14816              <div class="action-card-title">Git Browser</div>
14817              <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>
14818              <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>
14819            </div>
14820            <div class="action-card-sep"></div>
14821            <div class="action-card-right">
14822              <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>
14823              <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>
14824              <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>
14825            </div>
14826          </a>
14827          <a class="action-card automation card-split" href="/integrations">
14828            <div class="action-card-left">
14829              <div class="action-card-icon">
14830                <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>
14831              </div>
14832              <div class="action-card-title">Integrations</div>
14833              <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>
14834              <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>
14835            </div>
14836            <div class="action-card-sep"></div>
14837            <div class="action-card-right">
14838              <div class="ac-badges-grid">
14839                <span class="ac-badge github"     id="acp-gh">GitHub</span>
14840                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
14841                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
14842                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
14843              </div>
14844              <div class="ac-right-stat" id="acp-int-stat"></div>
14845            </div>
14846          </a>
14847        </div>
14848      </div>
14849
14850    </div>
14851
14852    {% if server_mode %}
14853    <div class="lan-card server">
14854      <div class="lan-card-header">
14855        <span class="lan-badge">LAN server</span>
14856        Accessible on your network
14857      </div>
14858      {% if let Some(ip) = lan_ip %}
14859      <div class="lan-url-row">
14860        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
14861        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
14862          <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>
14863          Copy URL
14864        </button>
14865      </div>
14866      <p class="lan-hint">Share this address with anyone on the same network.{% if has_api_key %} Authentication: enabled.{% else %} Authentication: not configured — all endpoints are open.{% endif %}</p>
14867      {% if has_api_key %}
14868      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
14869      {% endif %}
14870      {% else %}
14871      <p class="lan-hint">Could not auto-detect your LAN IP. Find it with <code>hostname -I</code> (Linux) or <code>ipconfig</code> (Windows), then open <code>http://&lt;your-ip&gt;:{{ port }}</code>.{% if has_api_key %} Authentication: enabled.{% else %} Authentication: not configured.{% endif %}</p>
14872      {% endif %}
14873    </div>
14874    {% endif %}
14875
14876    <div class="divider"></div>
14877
14878    <div class="info-strip">
14879      <div class="info-chip">
14880        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
14881        <div class="chip-slide">
14882          <div class="info-chip-val">41</div>
14883          <div class="info-chip-label">Languages</div>
14884        </div>
14885      </div>
14886      <div class="info-chip">
14887        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
14888        <div class="chip-slide">
14889          <div class="info-chip-val">100%</div>
14890          <div class="info-chip-label">Self-contained</div>
14891        </div>
14892      </div>
14893      <div class="info-chip">
14894        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
14895        <div class="chip-slide">
14896          <div class="info-chip-val">HTML+PDF</div>
14897          <div class="info-chip-label">Exportable reports</div>
14898        </div>
14899      </div>
14900      <div class="info-chip">
14901        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
14902        <div class="chip-slide">
14903          <div class="info-chip-val">Webhook</div>
14904          <div class="info-chip-label">3 platforms</div>
14905        </div>
14906      </div>
14907      <div class="info-chip">
14908        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
14909        <div class="chip-slide">
14910          <div class="info-chip-val">IEEE</div>
14911          <div class="info-chip-label">1045-1992</div>
14912        </div>
14913      </div>
14914    </div>
14915
14916    {% if lan_ip.is_none() %}
14917    <div class="lan-local-hint">
14918      <strong>Want teammates on the same network to access this?</strong><br>
14919      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
14920    </div>
14921    {% endif %}
14922  </div>
14923
14924  <footer class="site-footer">
14925    local code analysis - metrics, history and reports
14926    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
14927    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14928    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14929    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14930    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14931  </footer>
14932
14933  <script nonce="{{ csp_nonce }}">
14934    (function () {
14935      var storageKey = 'oxide-sloc-theme';
14936      var body = document.body;
14937      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
14938      var toggle = document.getElementById('theme-toggle');
14939      if (toggle) toggle.addEventListener('click', function () {
14940        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
14941        body.classList.toggle('dark-theme', next === 'dark');
14942        try { localStorage.setItem(storageKey, next); } catch(e) {}
14943      });
14944      var copyBtn = document.getElementById('lan-copy-btn');
14945      if (copyBtn) copyBtn.addEventListener('click', function() {
14946        var btn = this;
14947        var el = document.getElementById('lan-url-val');
14948        if (!el) return;
14949        var url = el.textContent.trim();
14950        if (navigator.clipboard) {
14951          navigator.clipboard.writeText(url).then(function() {
14952            var orig = btn.innerHTML;
14953            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!';
14954            setTimeout(function() { btn.innerHTML = orig; }, 1800);
14955          });
14956        }
14957      });
14958      (function randomizeWatermarks() {
14959        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14960        if (!wms.length) return;
14961        var placed = [];
14962        function tooClose(top, left) {
14963          for (var i = 0; i < placed.length; i++) {
14964            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
14965            if (dt < 16 && dl < 12) return true;
14966          }
14967          return false;
14968        }
14969        function pick(leftBand) {
14970          for (var attempt = 0; attempt < 50; attempt++) {
14971            var top = Math.random() * 88 + 2;
14972            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14973            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14974          }
14975          var top = Math.random() * 88 + 2;
14976          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14977          placed.push([top, left]); return [top, left];
14978        }
14979        var half = Math.floor(wms.length / 2);
14980        wms.forEach(function (img, i) {
14981          var pos = pick(i < half);
14982          var size = Math.floor(Math.random() * 100 + 120);
14983          var rot = (Math.random() * 360).toFixed(1);
14984          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
14985          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;
14986        });
14987      })();
14988
14989      (function spawnCodeParticles() {
14990        var container = document.getElementById('code-particles');
14991        if (!container) return;
14992        var snippets = [
14993          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
14994          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
14995          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
14996          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
14997          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
14998        ];
14999        var count = 38;
15000        for (var i = 0; i < count; i++) {
15001          (function(idx) {
15002            var el = document.createElement('span');
15003            el.className = 'code-particle';
15004            var text = snippets[idx % snippets.length];
15005            el.textContent = text;
15006            var left = Math.random() * 94 + 2;
15007            var top = Math.random() * 88 + 6;
15008            var dur = (Math.random() * 10 + 9).toFixed(1);
15009            var delay = (Math.random() * 18).toFixed(1);
15010            var rot = (Math.random() * 26 - 13).toFixed(1);
15011            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15012            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
15013              + '--rot:' + rot + 'deg;--op:' + op + ';'
15014              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
15015            container.appendChild(el);
15016          })(i);
15017        }
15018      })();
15019      (function heroAnimations() {
15020        var sub = document.getElementById('hero-subtitle');
15021        if (sub) {
15022          var full = sub.textContent.trim();
15023          sub.textContent = '';
15024          sub.style.opacity = '1';
15025          var cursor = document.createElement('span');
15026          cursor.className = 'hero-cursor';
15027          sub.appendChild(cursor);
15028          var i = 0;
15029          setTimeout(function() {
15030            var iv = setInterval(function() {
15031              if (i < full.length) {
15032                sub.insertBefore(document.createTextNode(full[i]), cursor);
15033                i++;
15034              } else {
15035                clearInterval(iv);
15036                setTimeout(function() {
15037                  cursor.style.transition = 'opacity 1s ease';
15038                  cursor.style.opacity = '0';
15039                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
15040                }, 2400);
15041              }
15042            }, 11);
15043          }, 374);
15044        }
15045      })();
15046      (function logoBob() {
15047        var logo = document.querySelector('.hero-logo');
15048        var shadow = document.querySelector('.hero-logo-shadow');
15049        if (!logo) return;
15050        var cycleStart = null, cycleDur = 3600;
15051        var peakY = -14, peakScale = 1.07, peakRot = 0;
15052        function newCycle() {
15053          cycleDur = 3000 + Math.random() * 1840;
15054          peakY = -(9 + Math.random() * 13.8);
15055          peakScale = 1.04 + Math.random() * 0.081;
15056          peakRot = (Math.random() * 11.5 - 5.75);
15057        }
15058        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
15059        newCycle();
15060        function frame(ts) {
15061          if (cycleStart === null) cycleStart = ts;
15062          var t = (ts - cycleStart) / cycleDur;
15063          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
15064          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
15065          var y = peakY * phase;
15066          var sc = 1 + (peakScale - 1) * phase;
15067          var rot = peakRot * Math.sin(Math.PI * phase);
15068          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
15069          if (shadow) {
15070            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
15071            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
15072          }
15073          requestAnimationFrame(frame);
15074        }
15075        requestAnimationFrame(frame);
15076      })();
15077      (function mouseEffects() {
15078        var heroTitle = document.getElementById('hero-title');
15079        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
15080        function tick() {
15081          raf = null;
15082          if (heroTitle) {
15083            var r = heroTitle.getBoundingClientRect();
15084            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
15085            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
15086            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
15087          }
15088        }
15089        document.addEventListener('mousemove', function(e) {
15090          mx = e.clientX; my = e.clientY;
15091          if (!raf) raf = requestAnimationFrame(tick);
15092        });
15093        document.addEventListener('mouseleave', function() {
15094          if (heroTitle) {
15095            heroTitle.style.transition = 'transform 0.5s ease';
15096            heroTitle.style.transform = '';
15097            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
15098          }
15099        });
15100        document.querySelectorAll('.action-card').forEach(function(card) {
15101          card.addEventListener('mousemove', function(e) {
15102            var rect = card.getBoundingClientRect();
15103            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
15104            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
15105            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
15106            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
15107          });
15108          card.addEventListener('mouseleave', function() {
15109            card.style.transition = '';
15110            card.style.transform = '';
15111          });
15112        });
15113      })();
15114      (function chipSlideshow() {
15115        var slides = [
15116          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
15117          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
15118          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
15119          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
15120          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
15121        ];
15122        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
15123        var indices = [0,0,0,0,0];
15124        var paused = [false,false,false,false,false];
15125        chips.forEach(function(chip, i) {
15126          chip.addEventListener('mouseenter', function() { paused[i] = true; });
15127          chip.addEventListener('mouseleave', function() { paused[i] = false; });
15128        });
15129        function advance(i) {
15130          if (paused[i]) return;
15131          var chip = chips[i];
15132          var inner = chip.querySelector('.chip-slide');
15133          if (!inner) return;
15134          inner.classList.add('fading');
15135          setTimeout(function() {
15136            indices[i] = (indices[i] + 1) % slides[i].length;
15137            var s = slides[i][indices[i]];
15138            chip.querySelector('.info-chip-val').textContent = s.v;
15139            chip.querySelector('.info-chip-label').textContent = s.l;
15140            inner.classList.remove('fading');
15141          }, 720);
15142        }
15143        setInterval(function() {
15144          chips.forEach(function(chip, i) { advance(i); });
15145        }, 6000);
15146      })();
15147      (function cardLiveData() {
15148        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
15149          var el = document.getElementById('acp-scan-stat');
15150          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
15151        }).catch(function(){});
15152        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
15153          var el = document.getElementById('acp-test-stat');
15154          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
15155        }).catch(function(){});
15156        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
15157          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
15158          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
15159          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
15160          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
15161          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
15162          var stat = document.getElementById('acp-int-stat');
15163          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
15164        }).catch(function(){});
15165        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
15166          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
15167        }).catch(function(){});
15168      })();
15169    })();
15170  </script>
15171  <script nonce="{{ csp_nonce }}">
15172  (function(){
15173    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'}];
15174    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);});}
15175    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15176    function init(){
15177      var btn=document.getElementById('settings-btn');if(!btn)return;
15178      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15179      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>';
15180      document.body.appendChild(m);
15181      var g=document.getElementById('scheme-grid');
15182      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);});
15183      var cl=document.getElementById('settings-close');
15184      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);
15185      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');});
15186      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15187      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15188    }
15189    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15190  }());
15191  </script>
15192  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
15193</body>
15194</html>
15195"##,
15196    ext = "html"
15197)]
15198struct SplashTemplate {
15199    csp_nonce: String,
15200    server_mode: bool,
15201    lan_ip: Option<String>,
15202    port: u16,
15203    version: &'static str,
15204    has_api_key: bool,
15205}
15206
15207// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
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 — Start a Scan</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:#ffffff; --surface-2:#fbf7f2;
15222      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15223      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15224      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15225      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
15226    }
15227    body.dark-theme {
15228      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
15229      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
15230    }
15231    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
15232    .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);}
15233    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15234    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
15235    .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));}
15236    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
15237    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
15238    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
15239    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15240    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15241    @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; } }
15242    .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;}
15243    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15244    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
15245    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15246    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15247    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15248    .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;}
15249    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15250    .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);}
15251    .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;}
15252    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15253    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15254    .settings-modal-body{padding:14px 16px 16px;}
15255    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15256    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15257    .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;}
15258    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15259    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15260    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15261    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15262    .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;}
15263    .tz-select:focus{border-color:var(--oxide);}
15264    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
15265    .page-header{text-align:center;margin-bottom:16px;}
15266    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
15267    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
15268    /* Cards */
15269    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
15270    .option-card-wrap{position:relative;}
15271    .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;}
15272    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
15273    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
15274    .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;}
15275    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
15276    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
15277    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
15278    .card-top-row{display:flex;align-items:center;gap:20px;}
15279    /* Two-column layout inside each card */
15280    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
15281    .card-left{display:flex;align-items:flex-start;min-width:0;}
15282    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
15283    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
15284    .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);}
15285    .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);}
15286    .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);}
15287    .card-text{min-width:0;}
15288    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
15289    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
15290    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
15291    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
15292    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
15293    /* Right CTA column */
15294    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
15295    .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;}
15296    /* Re-scan count badge */
15297    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
15298    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
15299    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
15300    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
15301    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
15302    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
15303    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
15304    body.dark-theme .btn-secondary{color:var(--oxide);}
15305    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
15306    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
15307    /* File input overlay — must be full-width so it aligns with other card-right buttons */
15308    .file-input-wrap{position:relative;width:100%;}
15309    .file-input-wrap .btn{width:100%;}
15310    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
15311    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15312    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15313    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15314    .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;}
15315    @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));}}
15316    /* Recent list (card 3 — full-width section below header) */
15317    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
15318    .recent-list{display:flex;flex-direction:column;gap:8px;}
15319    .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;}
15320    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
15321    .recent-item-info{flex:1;min-width:0;}
15322    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
15323    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
15324    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
15325    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
15326    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15327    .site-footer a{color:var(--muted);}
15328    @media(max-width:680px){
15329      .card-body{grid-template-columns:1fr;}
15330      .card-right{flex-direction:row;flex-wrap:wrap;}
15331      .btn{flex:1;}
15332    }
15333    .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;}
15334    .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;}
15335    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{visibility:hidden;opacity:0;pointer-events:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);border:1px solid rgba(255,255,255,0.10);transition:opacity 0.15s ease;}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip{visibility:visible;opacity:1;pointer-events:auto;}
15336  </style>
15337</head>
15338<body>
15339  <div class="background-watermarks" aria-hidden="true">
15340    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15341    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15342    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15343    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15344    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15345    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15346    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15347  </div>
15348  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15349  <div class="top-nav">
15350    <div class="top-nav-inner">
15351      <a class="brand" href="/">
15352        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15353        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15354      </a>
15355      <div class="nav-right">
15356        <a class="nav-pill" href="/">Home</a>
15357        <div class="nav-dropdown">
15358          <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>
15359          <div class="nav-dropdown-menu">
15360            <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>
15361          </div>
15362        </div>
15363        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15364        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15365        <div class="nav-dropdown">
15366          <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>
15367          <div class="nav-dropdown-menu">
15368            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
15369          </div>
15370        </div>
15371        <div class="server-status-wrap" id="server-status-wrap">
15372          <div class="nav-pill server-online-pill" id="server-status-pill">
15373            <span class="status-dot" id="status-dot"></span>
15374            <span id="server-status-label">Server</span>
15375            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15376          </div>
15377          <div class="server-status-tip">
15378            OxideSLOC is running — accessible on your network.
15379            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15380          </div>
15381        </div>
15382        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15383          <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>
15384        </button>
15385        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15386          <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>
15387          <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>
15388        </button>
15389      </div>
15390    </div>
15391  </div>
15392
15393  <div class="page">
15394    <div class="page-header">
15395      <h1>How would you like to scan?</h1>
15396      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
15397    </div>
15398
15399    <div class="option-grid">
15400
15401      <!-- Option 1: New scan -->
15402      <div class="option-card-wrap">
15403        <div class="option-card">
15404        <div class="option-icon new-scan">
15405          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
15406        </div>
15407        <div class="card-body">
15408          <div class="card-left">
15409            <div class="card-text">
15410              <div class="option-title">Start a new scan</div>
15411              <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>
15412              <ul class="feature-list">
15413                <li>Live project scope preview before you run</li>
15414                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
15415                <li>HTML, PDF, and JSON output — your choice</li>
15416              </ul>
15417            </div>
15418          </div>
15419          <div class="card-right">
15420            <a class="btn btn-primary" href="/scan">
15421              Configure &amp; scan
15422              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
15423            </a>
15424            <p class="card-tip">Full 4-step setup · all options</p>
15425          </div>
15426        </div>
15427        </div>
15428      </div>
15429
15430      <!-- Option 2: Load from config file -->
15431      <div class="option-card-wrap">
15432        <div class="option-card">
15433        <div class="option-icon load-config">
15434          <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>
15435        </div>
15436        <div class="card-body">
15437          <div class="card-left">
15438            <div class="card-text">
15439              <div class="option-title">Load a saved config</div>
15440              <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>
15441              <ul class="feature-list">
15442                <li>All 15 settings restored from the file</li>
15443                <li>Fully editable — change path or output dir</li>
15444                <li>Works with any scan-config.json</li>
15445              </ul>
15446            </div>
15447          </div>
15448          <div class="card-right">
15449            <div class="file-input-wrap">
15450              <button class="btn btn-secondary" id="load-config-btn" type="button">
15451                <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>
15452                Choose config file
15453              </button>
15454              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
15455            </div>
15456            <p class="card-tip" id="config-file-name">Exported after every scan</p>
15457          </div>
15458        </div>
15459        </div>
15460      </div>
15461
15462      <!-- Option 3: Re-scan recent project -->
15463      <div class="option-card-wrap">
15464        <div class="option-card" id="recent-card">
15465        <div class="card-top-row">
15466          <div class="option-icon rescan">
15467            <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>
15468          </div>
15469          <div class="card-body">
15470            <div class="card-left">
15471              <div class="card-text">
15472                <div class="option-title">Re-scan a recent project</div>
15473                <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>
15474                <ul class="feature-list">
15475                  <li>All 15+ settings restored from the saved config</li>
15476                  <li>Path and output dir are editable before running</li>
15477                  <li>Only scans with a saved config appear here</li>
15478                </ul>
15479              </div>
15480            </div>
15481            <div class="card-right">
15482              <div class="rescan-count-box">
15483                <div class="rescan-count-num" id="rescan-count-num">—</div>
15484                <div class="rescan-count-label">saved configs</div>
15485              </div>
15486              <a class="btn btn-secondary" href="/view-reports">
15487                <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>
15488                View all runs
15489              </a>
15490              <p class="card-tip">Opens run history</p>
15491            </div>
15492          </div>
15493        </div>
15494        <div class="section-divider"></div>
15495        <div class="recent-list" id="recent-list">
15496          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
15497        </div>
15498        </div>
15499      </div>
15500
15501    </div>
15502  </div>
15503
15504  <footer class="site-footer">
15505    local code analysis - metrics, history and reports
15506    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
15507    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15508    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15509    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15510    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
15511  </footer>
15512
15513  <script nonce="{{ csp_nonce }}">
15514    (function () {
15515      var storageKey = 'oxide-sloc-theme';
15516      var body = document.body;
15517      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15518      var toggle = document.getElementById('theme-toggle');
15519      if (toggle) toggle.addEventListener('click', function () {
15520        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15521        body.classList.toggle('dark-theme', next === 'dark');
15522        try { localStorage.setItem(storageKey, next); } catch(e) {}
15523      });
15524
15525      (function randomizeWatermarks() {
15526        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15527        if (!wms.length) return;
15528        var placed = [];
15529        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; }
15530        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]; }
15531        var half = Math.floor(wms.length / 2);
15532        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; });
15533      })();
15534      (function spawnCodeParticles() {
15535        var container = document.getElementById('code-particles');
15536        if (!container) return;
15537        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'];
15538        var count = 38;
15539        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); }
15540      })();
15541      // Recent scans data injected from server
15542      var recentScans = {{ recent_scans_json|safe }};
15543
15544      function configToParams(cfg) {
15545        var p = new URLSearchParams();
15546        p.set('prefilled', '1');
15547        if (cfg.path) p.set('path', cfg.path);
15548        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
15549        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
15550        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
15551        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
15552        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
15553        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
15554        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
15555        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
15556        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
15557        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
15558        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
15559        if (cfg.report_title) p.set('report_title', cfg.report_title);
15560        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
15561        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
15562        return p;
15563      }
15564
15565      // Build recent scan list (capped at 3 visible entries)
15566      var list = document.getElementById('recent-list');
15567      var noNote = document.getElementById('no-recent-note');
15568      var hasAny = false;
15569      var MAX_RECENT = 3;
15570      if (Array.isArray(recentScans)) {
15571        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
15572        var shown = 0;
15573        validEntries.forEach(function (entry) {
15574          if (shown >= MAX_RECENT) return;
15575          shown++;
15576          hasAny = true;
15577          var item = document.createElement('div');
15578          item.className = 'recent-item';
15579          item.title = 'Restore all settings and open wizard';
15580          item.innerHTML =
15581            '<div class="recent-item-info">' +
15582              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
15583              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
15584            '</div>' +
15585            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
15586          item.addEventListener('click', function () {
15587            var params = configToParams(entry.config);
15588            window.location.href = '/scan?' + params.toString();
15589          });
15590          list.appendChild(item);
15591        });
15592        if (validEntries.length > MAX_RECENT) {
15593          var moreEl = document.createElement('div');
15594          moreEl.className = 'recent-more-link';
15595          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
15596          list.appendChild(moreEl);
15597        }
15598      }
15599      if (hasAny && noNote) noNote.style.display = 'none';
15600      // Update count badge
15601      var countEl = document.getElementById('rescan-count-num');
15602      if (countEl) {
15603        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
15604        countEl.textContent = total > 0 ? total : '0';
15605      }
15606
15607      // Config file loader
15608      var fileInput = document.getElementById('config-file-input');
15609      var fileName = document.getElementById('config-file-name');
15610      if (fileInput) {
15611        fileInput.addEventListener('change', function () {
15612          var file = fileInput.files && fileInput.files[0];
15613          if (!file) return;
15614          if (fileName) fileName.textContent = '✓ ' + file.name;
15615          var reader = new FileReader();
15616          reader.onload = function (e) {
15617            try {
15618              var cfg = JSON.parse(e.target.result);
15619              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
15620              var params = configToParams(cfg);
15621              window.location.href = '/scan?' + params.toString();
15622            } catch (err) {
15623              alert('Could not parse config file: ' + err.message);
15624            }
15625          };
15626          reader.readAsText(file);
15627        });
15628      }
15629
15630      function escHtml(s) {
15631        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
15632      }
15633    })();
15634  </script>
15635  <script nonce="{{ csp_nonce }}">
15636  (function(){
15637    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'}];
15638    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);});}
15639    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15640    function init(){
15641      var btn=document.getElementById('settings-btn');if(!btn)return;
15642      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15643      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>';
15644      document.body.appendChild(m);
15645      var g=document.getElementById('scheme-grid');
15646      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);});
15647      var cl=document.getElementById('settings-close');
15648      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);
15649      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');});
15650      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15651      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15652    }
15653    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15654  }());
15655  </script>
15656  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
15657</body>
15658</html>
15659"##,
15660    ext = "html"
15661)]
15662struct ScanSetupTemplate {
15663    version: &'static str,
15664    recent_scans_json: String,
15665    csp_nonce: String,
15666}
15667
15668#[derive(Template)]
15669#[template(
15670    source = r##"
15671<!doctype html>
15672<html lang="en">
15673<head>
15674  <meta charset="utf-8">
15675  <meta name="viewport" content="width=device-width, initial-scale=1">
15676  <title>OxideSLOC | {{ report_title }} | Report</title>
15677  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15678  <style nonce="{{ csp_nonce }}">
15679    :root {
15680      --radius: 18px;
15681      --bg: #f5efe8;
15682      --surface: rgba(255,255,255,0.82);
15683      --surface-2: #fbf7f2;
15684      --surface-3: #efe6dc;
15685      --line: #e6d0bf;
15686      --line-strong: #dcb89f;
15687      --text: #43342d;
15688      --muted: #7b675b;
15689      --muted-2: #a08777;
15690      --nav: #b85d33;
15691      --nav-2: #7a371b;
15692      --accent: #6f9bff;
15693      --accent-2: #4a78ee;
15694      --oxide: #d37a4c;
15695      --oxide-2: #b35428;
15696      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
15697      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
15698      --success-bg: #e8f5ed;
15699      --success-text: #1a8f47;
15700      --info-bg: #eef3ff;
15701      --info-text: #4467d8;
15702    }
15703
15704    body.dark-theme {
15705      --bg: #1b1511;
15706      --surface: #261c17;
15707      --surface-2: #2d221d;
15708      --surface-3: #372922;
15709      --line: #524238;
15710      --line-strong: #6c5649;
15711      --text: #f5ece6;
15712      --muted: #c7b7aa;
15713      --muted-2: #aa9485;
15714      --nav: #b85d33;
15715      --nav-2: #7a371b;
15716      --accent: #6f9bff;
15717      --accent-2: #4a78ee;
15718      --oxide: #d37a4c;
15719      --oxide-2: #b35428;
15720      --shadow: 0 18px 42px rgba(0,0,0,0.28);
15721      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
15722      --success-bg: #163927;
15723      --success-text: #8fe2a8;
15724      --info-bg: #1c2847;
15725      --info-text: #a9c1ff;
15726    }
15727
15728    * { box-sizing: border-box; }
15729    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); }
15730    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15731    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15732    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15733    .top-nav, .page { position: relative; z-index: 2; }
15734    .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); }
15735    .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; }
15736    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15737    .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)); }
15738    .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; }
15739    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15740    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15741    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15742    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15743    .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; }
15744    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15745    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15746    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15747    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15748    @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; } }
15749    .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; }
15750    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15751    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15752    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15753    .theme-toggle .icon-sun { display:none; }
15754    body.dark-theme .theme-toggle .icon-sun { display:block; }
15755    body.dark-theme .theme-toggle .icon-moon { display:none; }
15756    .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;}
15757    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15758    .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);}
15759    .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;}
15760    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15761    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15762    .settings-modal-body{padding:14px 16px 16px;}
15763    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15764    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15765    .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;}
15766    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15767    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15768    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15769    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15770    .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;}
15771    .tz-select:focus{border-color:var(--oxide);}
15772    .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; }
15773    .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;}
15774    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
15775    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
15776    .hero, .panel { padding: 22px; }
15777    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
15778    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15779    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
15780    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
15781    .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; }
15782    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
15783    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
15784    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
15785    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
15786    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
15787    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
15788    .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; }
15789    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
15790    .delta-card-val { font-size:16px; font-weight:800; }
15791    .delta-card-val.pos { color:#1e7e34; }
15792    .delta-card-val.neg { color:var(--neg); }
15793    .delta-card-val.mod { color:#b35428; }
15794    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
15795    .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; }
15796    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15797    .delta-card-inline:hover .delta-card-tip { opacity:1; }
15798    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
15799    .compare-ts { font-size:13px; color:var(--muted); }
15800    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
15801    .compare-arrow { color: var(--muted); }
15802    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
15803    .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; }
15804    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
15805    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
15806    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
15807    .run-mgmt-card { flex:1; min-width:220px; padding:12px 16px; border-radius:14px; border:1px solid var(--line); background:var(--surface-2); display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center; }
15808    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
15809    .run-mgmt-card .action-buttons { justify-content:center; }
15810    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
15811    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
15812    .button, .copy-button {
15813      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;
15814    }
15815    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
15816    @keyframes spin { to { transform: rotate(360deg); } }
15817    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
15818    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
15819    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
15820    .path-item strong { display: block; margin-bottom: 6px; }
15821    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
15822    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
15823    .path-subitem { flex: 1; }
15824    .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); }
15825    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); }
15826    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
15827    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
15828    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
15829    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
15830    th { color: var(--muted); font-weight: 700; }
15831    tr:last-child td { border-bottom: none; }
15832    #subm-tbl col:nth-child(1){width:15%;}
15833    #subm-tbl col:nth-child(2){width:31%;}
15834    #subm-tbl col:nth-child(3){width:9%;}
15835    #subm-tbl col:nth-child(4){width:9%;}
15836    #subm-tbl col:nth-child(5){width:9%;}
15837    #subm-tbl col:nth-child(6){width:9%;}
15838    #subm-tbl col:nth-child(7){width:9%;}
15839    #subm-tbl col:nth-child(8){width:9%;}
15840    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
15841    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
15842    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
15843    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
15844    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
15845    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
15846    .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; }
15847    .soft-chip.success { gap:5px; padding:0 10px 0 8px; min-height:22px; background:rgba(26,143,71,0.06); color:var(--muted); border:1px solid rgba(26,143,71,0.18); font-size:11px; font-weight:600; letter-spacing:0.03em; }
15848    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
15849    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
15850    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
15851    .muted { color: var(--muted); }
15852    /* Run-ID chip row (mirrors HTML report) */
15853    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
15854    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
15855    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
15856    .run-id-chip { display:flex; flex-direction:column; gap:5px; padding:12px 14px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); border-left:3px solid var(--accent); color:var(--text); position:relative; cursor:default; transition:transform 0.18s ease,box-shadow 0.18s ease; min-width:0; }
15857    .run-id-chip[data-copy] { cursor:pointer; }
15858    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
15859    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
15860    .run-id-chip-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.1em; color:var(--accent); display:flex; align-items:center; gap:4px; }
15861    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
15862    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15863    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
15864    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
15865    a.commit-link-value { color:inherit; text-decoration:none; }
15866    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
15867    .chip-tooltip { 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; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity 0.18s ease; z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
15868    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15869    .run-id-chip:hover .chip-tooltip { opacity:1; }
15870    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
15871    .run-id-short-badge { font-family:ui-monospace,monospace; font-size:13px; font-weight:700; color:var(--muted); background:var(--surface-2); border:1px solid var(--line); border-radius:6px; padding:2px 8px; letter-spacing:0.04em; white-space:nowrap; align-self:center; }
15872    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
15873    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
15874    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
15875    /* Meta chips row */
15876    .meta { display:flex; flex-wrap:wrap; align-items:center; gap:0; margin:14px 0 0; padding:10px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); width:100%; }
15877    .meta-chip { flex:1; display:inline-flex; align-items:center; justify-content:center; gap:5px; padding:0 10px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
15878    .meta-chip:last-child { border-right:none; }
15879    .meta-chip b { color:var(--text); font-weight:700; }
15880    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15881    .site-footer a{color:var(--muted);}
15882    .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; }
15883    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
15884    .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; }
15885    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
15886    /* Stat chips (matches HTML report) */
15887    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
15888    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
15889    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15890    .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; }
15891    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
15892    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
15893    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
15894    .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; }
15895    .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; }
15896    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15897    .stat-chip:hover .stat-chip-tip { opacity:1; }
15898    /* Submodule panel */
15899    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
15900    /* Metrics tables stack */
15901    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
15902    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
15903    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
15904    .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)); }
15905    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
15906    /* Metrics table */
15907    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
15908    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
15909    .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; }
15910    .metrics-table thead th:not(:first-child) { text-align: right; }
15911    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
15912    .metrics-table tbody tr:last-child td { border-bottom: none; }
15913    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
15914    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
15915    .metrics-table tbody tr:hover td { background: var(--surface-2); }
15916    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
15917    .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; }
15918    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
15919    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
15920    .mt-val-pos { color: var(--pos); font-weight: 700; }
15921    .mt-val-neg { color: var(--neg); font-weight: 700; }
15922    .mt-val-zero { color: var(--muted); }
15923    .mt-val-mod { color: var(--oxide-2); }
15924    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
15925    @media (max-width: 1180px) {
15926      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
15927      .nav-project-slot, .nav-status { justify-content:flex-start; }
15928      .hero-top { flex-direction: column; }
15929      .run-mgmt-strip { flex-direction: column; }
15930    }
15931    .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;}
15932    @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));}}
15933    .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;}
15934    /* ── Result-page chart controls ─────────────────────────────────────────── */
15935    .r-chart-section{margin-bottom:24px;}
15936    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
15937    .section-pair > .panel{flex-shrink:0;}
15938    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
15939    .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;}
15940    .r-chart-select:focus{border-color:var(--accent);}
15941    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
15942    .r-chart-container svg{display:block;width:100%;height:auto;}
15943    .r-expand-btn{background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;color:var(--muted);padding:3px 8px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;}
15944    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
15945    .r-chart-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
15946    .r-chart-modal{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:960px;width:100%;max-height:85vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
15947    .r-chart-modal-title{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}
15948    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}
15949    .r-chart-modal-close{position:absolute;top:14px;right:18px;background:none;border:none;font-size:22px;cursor:pointer;color:var(--text);line-height:1;padding:0;}
15950    .r-chart-modal-close:hover{opacity:.7;}
15951    body.dark-theme .r-chart-modal{background:var(--surface);}
15952    .r-chart-container .rchit,.r-expand-modal-chart .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
15953    .r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover{opacity:.75;filter:brightness(1.14);}
15954    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
15955    .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;}
15956    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
15957    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
15958    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
15959    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
15960    #r-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:10px;padding:8px 13px;font-size:12px;line-height:1.5;pointer-events:none;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
15961    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
15962    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
15963    .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;}
15964    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
15965    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
15966    .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface);box-shadow:var(--shadow);display:flex;flex-direction:column;}
15967    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
15968    .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%;}
15969    .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%;}
15970    body.has-report-banner .top-nav{top:27px;}
15971    body.has-report-banner{padding-bottom:27px;}
15972  </style>
15973</head>
15974<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
15975  <div class="background-watermarks" aria-hidden="true">
15976    <img src="/images/logo/logo-text.png" alt="" />
15977    <img src="/images/logo/logo-text.png" alt="" />
15978    <img src="/images/logo/logo-text.png" alt="" />
15979    <img src="/images/logo/logo-text.png" alt="" />
15980    <img src="/images/logo/logo-text.png" alt="" />
15981    <img src="/images/logo/logo-text.png" alt="" />
15982    <img src="/images/logo/logo-text.png" alt="" />
15983    <img src="/images/logo/logo-text.png" alt="" />
15984    <img src="/images/logo/logo-text.png" alt="" />
15985    <img src="/images/logo/logo-text.png" alt="" />
15986    <img src="/images/logo/logo-text.png" alt="" />
15987    <img src="/images/logo/logo-text.png" alt="" />
15988    <img src="/images/logo/logo-text.png" alt="" />
15989    <img src="/images/logo/logo-text.png" alt="" />
15990  </div>
15991  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15992  {% if let Some(banner) = report_header_footer %}
15993  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
15994  {% endif %}
15995  <div class="top-nav">
15996    <div class="top-nav-inner">
15997      <a class="brand" href="/">
15998        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15999        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16000      </a>
16001      <div class="nav-project-slot">
16002        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
16003      </div>
16004      <div class="nav-status">
16005        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
16006        <div class="nav-dropdown">
16007          <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>
16008          <div class="nav-dropdown-menu">
16009            <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>
16010          </div>
16011        </div>
16012        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
16013        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16014        <div class="nav-dropdown">
16015          <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>
16016          <div class="nav-dropdown-menu">
16017            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
16018          </div>
16019        </div>
16020        <div class="server-status-wrap" id="server-status-wrap">
16021          <div class="nav-pill server-online-pill" id="server-status-pill">
16022            <span class="status-dot" id="status-dot"></span>
16023            <span id="server-status-label">Server</span>
16024            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16025          </div>
16026          <div class="server-status-tip">
16027            OxideSLOC is running — accessible on your network.
16028            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16029          </div>
16030        </div>
16031        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16032          <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>
16033        </button>
16034        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
16035          <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>
16036          <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>
16037        </button>
16038      </div>
16039    </div>
16040  </div>
16041
16042  <div class="page">
16043    <section class="hero">
16044      <div class="hero-top">
16045        <div>
16046          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
16047            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
16048            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
16049            <div class="soft-chip success" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
16050          </div>
16051        </div>
16052        <div class="hero-quick-actions">
16053          {% if server_mode %}
16054          <button type="button" class="copy-button secondary" disabled title="Output folder is on the server — path is not meaningful for remote users" style="opacity:0.45;cursor:not-allowed;">Copy output folder</button>
16055          {% else %}
16056          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
16057          {% endif %}
16058          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
16059          {% if !server_mode %}
16060          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
16061          {% endif %}
16062        </div>
16063      </div>
16064
16065      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
16066      <div class="run-id-row">
16067        <span class="run-id-chip" data-copy="{{ run_id }}">
16068          <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>Run ID</span>
16069          <span class="run-id-chip-value">{{ run_id }}</span>
16070          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
16071        </span>
16072        {% match git_commit_long %}
16073          {% when Some with (long_sha) %}
16074          {% match git_commit_url %}
16075            {% when Some with (commit_url) %}
16076            <span class="run-id-chip" data-copy="{{ long_sha }}">
16077              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
16078              <a href="{{ commit_url }}" target="_blank" rel="noopener" class="run-id-chip-value commit-link-value" onclick="event.stopPropagation()">{{ long_sha }}</a>
16079              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
16080            </span>
16081            {% when None %}
16082            <span class="run-id-chip" data-copy="{{ long_sha }}">
16083              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
16084              <span class="run-id-chip-value">{{ long_sha }}</span>
16085              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
16086            </span>
16087          {% endmatch %}
16088          {% when None %}
16089          <span class="run-id-chip muted-chip">
16090            <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
16091            <span class="run-id-chip-value">Not detected</span>
16092            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
16093          </span>
16094        {% endmatch %}
16095        {% match git_branch %}
16096          {% when Some with (branch) %}
16097          <span class="run-id-chip">
16098            <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
16099            <span class="run-id-chip-value">{{ branch }}</span>
16100            <span class="chip-tooltip">Git branch active at scan time</span>
16101          </span>
16102          {% when None %}
16103          <span class="run-id-chip muted-chip">
16104            <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
16105            <span class="run-id-chip-value">Not detected</span>
16106            <span class="chip-tooltip">No Git branch was found for this scan</span>
16107          </span>
16108        {% endmatch %}
16109        {% match git_author %}
16110          {% when Some with (author) %}
16111          <span class="run-id-chip" data-author="{{ author }}">
16112            <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
16113            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
16114            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
16115          </span>
16116          {% when None %}
16117          <span class="run-id-chip muted-chip">
16118            <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
16119            <span class="run-id-chip-value">Not detected</span>
16120            <span class="chip-tooltip">No commit author was found for this scan</span>
16121          </span>
16122        {% endmatch %}
16123      </div>
16124
16125      <!-- Scan metadata row -->
16126      <div class="meta">
16127        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
16128        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
16129        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
16130        <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
16131        <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
16132      </div>
16133
16134      <!-- 12 summary stat chips -->
16135      <div class="summary-strip">
16136        <div class="stat-chip" data-raw="{{ physical_lines }}">
16137          <div class="stat-chip-label">Physical lines</div>
16138          <div class="stat-chip-val">{{ physical_lines }}</div>
16139          <div class="stat-chip-exact"></div>
16140          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
16141        </div>
16142        <div class="stat-chip" data-raw="{{ code_lines }}">
16143          <div class="stat-chip-label">Code</div>
16144          <div class="stat-chip-val">{{ code_lines }}</div>
16145          <div class="stat-chip-exact"></div>
16146          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
16147        </div>
16148        <div class="stat-chip" data-raw="{{ comment_lines }}">
16149          <div class="stat-chip-label">Comments</div>
16150          <div class="stat-chip-val">{{ comment_lines }}</div>
16151          <div class="stat-chip-exact"></div>
16152          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
16153        </div>
16154        <div class="stat-chip" data-raw="{{ blank_lines }}">
16155          <div class="stat-chip-label">Blank</div>
16156          <div class="stat-chip-val">{{ blank_lines }}</div>
16157          <div class="stat-chip-exact"></div>
16158          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
16159        </div>
16160        <div class="stat-chip" data-raw="{{ mixed_lines }}">
16161          <div class="stat-chip-label">Mixed separate</div>
16162          <div class="stat-chip-val">{{ mixed_lines }}</div>
16163          <div class="stat-chip-exact"></div>
16164          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
16165        </div>
16166        <div class="stat-chip" data-raw="{{ functions }}">
16167          <div class="stat-chip-label">Functions</div>
16168          <div class="stat-chip-val">{{ functions }}</div>
16169          <div class="stat-chip-exact"></div>
16170          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
16171        </div>
16172        <div class="stat-chip" data-raw="{{ classes }}">
16173          <div class="stat-chip-label">Classes / Types</div>
16174          <div class="stat-chip-val">{{ classes }}</div>
16175          <div class="stat-chip-exact"></div>
16176          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
16177        </div>
16178        <div class="stat-chip" data-raw="{{ variables }}">
16179          <div class="stat-chip-label">Variables</div>
16180          <div class="stat-chip-val">{{ variables }}</div>
16181          <div class="stat-chip-exact"></div>
16182          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
16183        </div>
16184        <div class="stat-chip" data-raw="{{ imports }}">
16185          <div class="stat-chip-label">Imports</div>
16186          <div class="stat-chip-val">{{ imports }}</div>
16187          <div class="stat-chip-exact"></div>
16188          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
16189        </div>
16190        <div class="stat-chip" data-raw="{{ test_count }}">
16191          <div class="stat-chip-label">Tests</div>
16192          <div class="stat-chip-val">{{ test_count }}</div>
16193          <div class="stat-chip-exact"></div>
16194          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
16195        </div>
16196        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
16197          <div class="stat-chip-label">Code density</div>
16198          <div class="stat-chip-val stat-chip-density-val">—</div>
16199          <div class="stat-chip-exact"></div>
16200          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
16201        </div>
16202        <div class="stat-chip" data-raw="{{ files_analyzed }}">
16203          <div class="stat-chip-label">Files analyzed</div>
16204          <div class="stat-chip-val">{{ files_analyzed }}</div>
16205          <div class="stat-chip-exact"></div>
16206          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
16207        </div>
16208      </div>
16209
16210      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
16211      <div class="compare-banner">
16212        <div class="compare-banner-body">
16213          <div class="compare-banner-meta">
16214            <span class="compare-label">Previous scan</span>
16215            <span class="compare-ts">{{ prev_ts }}</span>
16216            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
16217            {% if let Some(prev_code) = prev_run_code_lines %}
16218            <div class="compare-banner-stats" style="margin-top:4px;">
16219              <span>Code before: <strong>{{ prev_code }}</strong></span>
16220              <span class="compare-arrow">→</span>
16221              <span>Code now: <strong>{{ code_lines }}</strong></span>
16222              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
16223              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
16224            </div>
16225            {% endif %}
16226          </div>
16227          {% if delta_lines_added.is_some() %}
16228          <div class="delta-cards-inline">
16229            <div class="delta-card-inline">
16230              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
16231              <div class="delta-card-lbl">lines added</div>
16232              <div class="delta-card-tip">Code lines added since the previous scan</div>
16233            </div>
16234            <div class="delta-card-inline">
16235              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
16236              <div class="delta-card-lbl">lines removed</div>
16237              <div class="delta-card-tip">Code lines removed since the previous scan</div>
16238            </div>
16239            <div class="delta-card-inline">
16240              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
16241              <div class="delta-card-lbl">unmodified lines</div>
16242              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
16243            </div>
16244            <div class="delta-card-inline">
16245              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
16246              <div class="delta-card-lbl">files modified</div>
16247              <div class="delta-card-tip">Files with at least one line changed</div>
16248            </div>
16249            <div class="delta-card-inline">
16250              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
16251              <div class="delta-card-lbl">files added</div>
16252              <div class="delta-card-tip">New files added since the previous scan</div>
16253            </div>
16254            <div class="delta-card-inline">
16255              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
16256              <div class="delta-card-lbl">files removed</div>
16257              <div class="delta-card-tip">Files deleted since the previous scan</div>
16258            </div>
16259            <div class="delta-card-inline">
16260              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
16261              <div class="delta-card-lbl">files unchanged</div>
16262              <div class="delta-card-tip">Files with no changes since the previous scan</div>
16263            </div>
16264          </div>
16265          {% else %}
16266          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
16267            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
16268          </p>
16269          {% endif %}
16270          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
16271        </div>
16272      </div>
16273      {% endif %}{% endif %}
16274
16275      <div class="action-grid">
16276        <div class="action-card">
16277          <h3>HTML report</h3>
16278          <div class="action-buttons">
16279            {% match html_url %}
16280              {% when Some with (url) %}
16281                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
16282              {% when None %}{% endmatch %}
16283            {% match html_download_url %}
16284              {% when Some with (url) %}
16285                <a class="button secondary" href="{{ url }}">Download HTML</a>
16286              {% when None %}{% endmatch %}
16287            {% match html_path %}
16288              {% when Some with (_path) %}{% when None %}{% endmatch %}
16289            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
16290          </div>
16291        </div>
16292        <div class="action-card">
16293          <h3>PDF report</h3>
16294          <div class="action-buttons">
16295            {% match pdf_url %}
16296              {% when Some with (url) %}
16297                {% if pdf_generating %}
16298                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
16299                    <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>
16300                    Generating PDF…
16301                  </button>
16302                {% else %}
16303                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
16304                {% endif %}
16305              {% when None %}
16306                {% match html_url %}
16307                  {% when Some with (_hurl) %}
16308                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
16309                    <p class="action-empty-note" style="margin-top:6px;font-size:11px;">Generates the PDF report from the scan results. Usually completes within a few seconds.</p>
16310                  {% when None %}
16311                    <p class="action-empty-note" style="color:var(--muted);font-size:12px;background:rgba(0,0,0,0.04);border:1px solid var(--line);border-radius:8px;padding:10px 12px;">
16312                      PDF and HTML reports were not generated for this run. Re-run with HTML or PDF output enabled.
16313                    </p>
16314                {% endmatch %}
16315            {% endmatch %}
16316            {% match pdf_download_url %}
16317              {% when Some with (url) %}
16318                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
16319              {% when None %}{% endmatch %}
16320            {% match pdf_url %}
16321              {% when Some with (_) %}
16322                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
16323              {% when None %}{% endmatch %}
16324          </div>
16325        </div>
16326        <div class="action-card">
16327          <h3>JSON result</h3>
16328          <div class="action-buttons">
16329            {% match json_url %}
16330              {% when Some with (url) %}
16331                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
16332              {% when None %}{% endmatch %}
16333            {% match json_download_url %}
16334              {% when Some with (url) %}
16335                <a class="button secondary" href="{{ url }}">Download JSON</a>
16336              {% when None %}{% endmatch %}
16337            {% match json_path %}
16338              {% when Some with (_path) %}
16339                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
16340              {% when None %}
16341                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
16342              {% endmatch %}
16343          </div>
16344        </div>
16345        <div class="action-card">
16346          <h3>Scan config</h3>
16347          <div class="action-buttons">
16348            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
16349            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
16350            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
16351          </div>
16352        </div>
16353        {% if confluence_configured %}
16354        <div class="action-card" id="confluenceCard">
16355          <h3>Confluence</h3>
16356          <div class="action-buttons">
16357            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
16358            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
16359          </div>
16360          <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>
16361        </div>
16362        {% endif %}
16363      </div>
16364      <div class="run-mgmt-strip">
16365        <div class="run-mgmt-card">
16366          <h3>Download bundle</h3>
16367          <div class="action-buttons">
16368            <button class="button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
16369          </div>
16370          <p class="action-empty-note" style="margin-top:6px;">Downloads a .tar.gz archive containing every artifact for this run (HTML, PDF, JSON, CSV, scan config).</p>
16371        </div>
16372        <div class="run-mgmt-card" id="delete-run-card">
16373          <h3>Delete run</h3>
16374          <div class="action-buttons">
16375            <button class="button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete this run</button>
16376          </div>
16377          <p class="action-empty-note" style="margin-top:6px;">Permanently removes all artifacts for this run from disk. This action cannot be undone.</p>
16378        </div>
16379      </div>
16380      {% if confluence_configured %}
16381      <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;">
16382        <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);">
16383          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
16384          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
16385          <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;">
16386          <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>
16387          <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;">
16388          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16389          <div style="display:flex;gap:10px;justify-content:flex-end;">
16390            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
16391            <button class="button" id="confSubmitBtn" type="button">Post</button>
16392          </div>
16393        </div>
16394      </div>
16395      {% endif %}
16396      <div id="delete-run-modal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.55);align-items:center;justify-content:center;">
16397        <div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:460px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">
16398          <div style="font-size:16px;font-weight:800;margin-bottom:10px;color:#b23030;">Delete run — irreversible</div>
16399          <p style="font-size:13px;color:var(--text);margin:0 0 18px;">This will permanently delete all artifacts for this run from disk (HTML, PDF, JSON, CSV, scan config). <strong>This cannot be undone</strong> and the run will no longer be accessible by anyone.</p>
16400          <div id="delete-run-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16401          <div style="display:flex;gap:10px;justify-content:flex-end;">
16402            <button class="button secondary" id="delete-run-cancel" type="button">Cancel</button>
16403            <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;">Yes, delete permanently</button>
16404          </div>
16405        </div>
16406      </div>
16407      {% if !submodule_rows.is_empty() %}
16408      <div class="submodule-panel">
16409        <div class="toolbar-row">
16410          <div>
16411            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
16412            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
16413          </div>
16414          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
16415        </div>
16416        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
16417        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
16418          <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>
16419          <thead>
16420            <tr>
16421              <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>
16422              <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>
16423              <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>
16424              <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>
16425              <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>
16426              <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>
16427              <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>
16428              <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>
16429            </tr>
16430          </thead>
16431          <tbody>
16432            {% for row in submodule_rows %}
16433            <tr>
16434              <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>
16435              <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>
16436              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
16437              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
16438              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
16439              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
16440              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
16441              <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>
16442            </tr>
16443            {% endfor %}
16444          </tbody>
16445        </table>
16446        </div>
16447      </div>
16448      {% endif %}
16449
16450      <div class="metrics-tables-stack">
16451
16452        <div class="metrics-table-wrap">
16453          <div class="metrics-table-title">Files</div>
16454          <table class="metrics-table">
16455            <thead>
16456              <tr>
16457                <th>Metric</th>
16458                <th>This Run</th>
16459                <th>Previous</th>
16460                <th>Change</th>
16461              </tr>
16462            </thead>
16463            <tbody>
16464              <tr>
16465                <td>Files analyzed</td>
16466                <td class="mt-val-large">{{ files_analyzed }}</td>
16467                <td>{{ prev_fa_str }}</td>
16468                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
16469              </tr>
16470              <tr>
16471                <td>Files skipped</td>
16472                <td>{{ files_skipped }}</td>
16473                <td>{{ prev_fs_str }}</td>
16474                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
16475              </tr>
16476              <tr>
16477                <td>Files modified</td>
16478                <td class="mt-val-na">—</td>
16479                <td class="mt-val-na">—</td>
16480                <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>
16481              </tr>
16482              <tr>
16483                <td>Files unchanged</td>
16484                <td class="mt-val-na">—</td>
16485                <td class="mt-val-na">—</td>
16486                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
16487              </tr>
16488            </tbody>
16489          </table>
16490        </div>
16491
16492        <div class="metrics-table-wrap">
16493          <div class="metrics-table-title">Line Counts</div>
16494          <table class="metrics-table">
16495            <thead>
16496              <tr>
16497                <th>Metric</th>
16498                <th>This Run</th>
16499                <th>Previous</th>
16500                <th>Change</th>
16501              </tr>
16502            </thead>
16503            <tbody>
16504              <tr>
16505                <td>Physical lines</td>
16506                <td class="mt-val-large">{{ physical_lines }}</td>
16507                <td>{{ prev_pl_str }}</td>
16508                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
16509              </tr>
16510              <tr>
16511                <td>Code lines</td>
16512                <td class="mt-val-large">{{ code_lines }}</td>
16513                <td>{{ prev_cl_str }}</td>
16514                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
16515              </tr>
16516              <tr>
16517                <td>Comment lines</td>
16518                <td>{{ comment_lines }}</td>
16519                <td>{{ prev_cml_str }}</td>
16520                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
16521              </tr>
16522              <tr>
16523                <td>Blank lines</td>
16524                <td>{{ blank_lines }}</td>
16525                <td>{{ prev_bl_str }}</td>
16526                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
16527              </tr>
16528              <tr>
16529                <td>Mixed (separate)</td>
16530                <td>{{ mixed_lines }}</td>
16531                <td class="mt-val-na">—</td>
16532                <td class="mt-val-na">—</td>
16533              </tr>
16534            </tbody>
16535          </table>
16536        </div>
16537
16538        <div class="metrics-tables-lower">
16539          <div class="metrics-table-wrap">
16540            <div class="metrics-table-title">Code Structure</div>
16541            <table class="metrics-table">
16542              <thead>
16543                <tr>
16544                  <th>Metric</th>
16545                  <th>This Run</th>
16546                </tr>
16547              </thead>
16548              <tbody>
16549                <tr>
16550                  <td>Functions</td>
16551                  <td>{{ functions }}</td>
16552                </tr>
16553                <tr>
16554                  <td>Classes / Types</td>
16555                  <td>{{ classes }}</td>
16556                </tr>
16557                <tr>
16558                  <td>Variables</td>
16559                  <td>{{ variables }}</td>
16560                </tr>
16561                <tr>
16562                  <td>Imports</td>
16563                  <td>{{ imports }}</td>
16564                </tr>
16565              </tbody>
16566            </table>
16567          </div>
16568
16569          <div class="metrics-table-wrap">
16570            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
16571            <table class="metrics-table">
16572              <thead>
16573                <tr>
16574                  <th>Metric</th>
16575                  <th>Change</th>
16576                </tr>
16577              </thead>
16578              <tbody>
16579                <tr>
16580                  <td>Lines added</td>
16581                  <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>
16582                </tr>
16583                <tr>
16584                  <td>Lines removed</td>
16585                  <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>
16586                </tr>
16587                <tr>
16588                  <td>Lines modified (net)</td>
16589                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
16590                </tr>
16591                <tr>
16592                  <td>Lines unmodified</td>
16593                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
16594                </tr>
16595              </tbody>
16596            </table>
16597          </div>
16598        </div>
16599
16600      </div>
16601
16602      <div class="path-list">
16603        <div class="path-item">
16604          <div class="path-item-label">Project path</div>
16605          <code>{{ project_path }}</code>
16606        </div>
16607        <div class="path-item">
16608          <div class="path-item-label">Git branch</div>
16609          {% if let Some(branch) = git_branch %}
16610          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
16611          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
16612          {% else %}
16613          <code style="color:var(--muted)">—</code>
16614          {% endif %}
16615        </div>
16616        <div class="path-item">
16617          <div class="path-item-label">Output folder</div>
16618          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
16619        </div>
16620        <div class="path-item">
16621          <div class="path-item-label">Run ID</div>
16622          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
16623            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
16624            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
16625          </div>
16626        </div>
16627      </div>
16628    </section>
16629
16630    <div class="section-pair">
16631    <section class="panel">
16632        <div class="toolbar-row">
16633          <div>
16634            <h2>Language breakdown</h2>
16635            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
16636          </div>
16637        </div>
16638        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
16639    </section>
16640
16641    <section class="panel r-chart-section">
16642      <div class="toolbar-row" style="margin-bottom:16px;">
16643        <div>
16644          <h2>Visualizations</h2>
16645          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
16646        </div>
16647      </div>
16648
16649      <div class="r-viz-grid">
16650        <div class="r-viz-card">
16651          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16652            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
16653            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16654          </div>
16655          <div class="r-chart-tab-bar">
16656            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
16657            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
16658          </div>
16659          <div class="r-chart-container" id="r-composition-chart"></div>
16660        </div>
16661        <div class="r-viz-card">
16662          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16663            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
16664            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16665          </div>
16666          <div class="r-chart-container" id="r-scatter-chart"></div>
16667        </div>
16668        {% if has_semantic_data %}
16669        <div class="r-viz-card">
16670          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16671            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
16672            <select class="r-chart-select" id="r-semantic-metric">
16673              <option value="functions">Functions</option>
16674              <option value="classes">Classes</option>
16675              <option value="variables">Variables</option>
16676              <option value="imports">Imports</option>
16677            </select>
16678            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16679          </div>
16680          <div class="r-chart-container" id="r-semantic-chart"></div>
16681        </div>
16682        {% endif %}
16683        <div class="r-viz-card">
16684          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16685            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
16686            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16687          </div>
16688          <div class="r-chart-container" id="r-density-chart"></div>
16689        </div>
16690        <div class="r-viz-card">
16691          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16692            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
16693            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16694          </div>
16695          <div class="r-chart-container" id="r-avglines-chart"></div>
16696        </div>
16697        <div class="r-viz-card">
16698          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
16699            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
16700            <select class="r-chart-select" id="r-sub-metric">
16701              <option value="code">Code Lines</option>
16702              <option value="comment">Comments</option>
16703              <option value="blank">Blank Lines</option>
16704              <option value="physical">Physical Lines</option>
16705              <option value="files">Files</option>
16706            </select>
16707            <select class="r-chart-select" id="r-sub-sort">
16708              <option value="desc">Value ↓</option>
16709              <option value="asc">Value ↑</option>
16710              <option value="name">Name A→Z</option>
16711            </select>
16712            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16713          </div>
16714          <div class="r-chart-container" id="r-submodule-chart"></div>
16715        </div>
16716      </div>
16717
16718    </section>
16719    </div>
16720
16721  </div>
16722
16723  <div id="r-tt" aria-hidden="true"></div>
16724
16725  <script nonce="{{ csp_nonce }}">
16726    (function () {
16727      var body = document.body;
16728      var themeToggle = document.getElementById('theme-toggle');
16729      var storageKey = 'oxide-sloc-theme';
16730
16731      function applyTheme(theme) {
16732        body.classList.toggle('dark-theme', theme === 'dark');
16733      }
16734
16735      function loadSavedTheme() {
16736        try {
16737          var saved = localStorage.getItem(storageKey);
16738          if (saved === 'dark' || saved === 'light') {
16739            applyTheme(saved);
16740          }
16741        } catch (e) {}
16742      }
16743
16744      if (themeToggle) {
16745        themeToggle.addEventListener('click', function () {
16746          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
16747          applyTheme(nextTheme);
16748          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
16749        });
16750      }
16751
16752      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
16753        button.addEventListener('click', function () {
16754          var value = button.getAttribute('data-copy-value') || '';
16755          if (!value) return;
16756          var originalText = button.textContent;
16757          function flashSuccess() {
16758            button.textContent = 'Copied!';
16759            setTimeout(function () { button.textContent = originalText; }, 1800);
16760          }
16761          function flashFail() {
16762            button.textContent = 'Copy failed';
16763            setTimeout(function () { button.textContent = originalText; }, 2000);
16764          }
16765          if (navigator.clipboard && navigator.clipboard.writeText) {
16766            navigator.clipboard.writeText(value).then(flashSuccess, function () {
16767              fallbackCopy(value, flashSuccess, flashFail);
16768            });
16769          } else {
16770            fallbackCopy(value, flashSuccess, flashFail);
16771          }
16772        });
16773      });
16774      function fallbackCopy(text, onSuccess, onFail) {
16775        try {
16776          var ta = document.createElement('textarea');
16777          ta.value = text;
16778          ta.style.position = 'fixed';
16779          ta.style.top = '-9999px';
16780          ta.style.left = '-9999px';
16781          document.body.appendChild(ta);
16782          ta.focus();
16783          ta.select();
16784          var ok = document.execCommand('copy');
16785          document.body.removeChild(ta);
16786          if (ok) { onSuccess(); } else { onFail(); }
16787        } catch (e) { onFail(); }
16788      }
16789
16790      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16791        btn.addEventListener('click', function () {
16792          var folder = btn.getAttribute('data-folder') || '';
16793          if (!folder) return;
16794          fetch('/open-path?path=' + encodeURIComponent(folder))
16795            .then(function (r) { return r.json(); })
16796            .then(function (d) {
16797              if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16798            })
16799            .catch(function () {});
16800        });
16801      });
16802
16803      loadSavedTheme();
16804
16805      // ── Compact number formatting for stat chips ──────────────────────────
16806      (function(){
16807        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();}
16808        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
16809          var raw=parseInt(chip.getAttribute('data-raw'),10);
16810          if(isNaN(raw))return;
16811          var valEl=chip.querySelector('.stat-chip-val');
16812          if(valEl)valEl.textContent=fmt(raw);
16813          var exactEl=chip.querySelector('.stat-chip-exact');
16814          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
16815        });
16816        // Code density chip
16817        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
16818          var code=parseInt(chip.getAttribute('data-code'),10);
16819          var phys=parseInt(chip.getAttribute('data-physical'),10);
16820          if(isNaN(code)||isNaN(phys)||phys===0)return;
16821          var pct=(code/phys*100).toFixed(1)+'%';
16822          var valEl=chip.querySelector('.stat-chip-val');
16823          if(valEl)valEl.textContent=pct;
16824        });
16825        // Populate author handle from data-author attribute
16826        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
16827          var author=chip.getAttribute('data-author');
16828          var el=chip.querySelector('.author-handle');
16829          if(el)el.textContent='/'+author.replace(/\s+/g,'');
16830        });
16831        // Click-to-copy on run-id-chip elements
16832        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
16833          chip.addEventListener('click',function(){
16834            var val=chip.getAttribute('data-copy');
16835            if(!val)return;
16836            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
16837            else{var ta=document.createElement('textarea');ta.value=val;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(e){}document.body.removeChild(ta);}
16838            chip.classList.add('chip-copied-flash');
16839            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
16840          });
16841        });
16842      })();
16843
16844      // ── Shared tooltip for all result-page charts ─────────────────────────
16845      var rTT=(function(){
16846        var el=document.getElementById('r-tt');
16847        if(!el)return{s:function(){},h:function(){},m:function(){}};
16848        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
16849        function hide(){el.style.display='none';}
16850        function move(e){
16851          var x=e.clientX+16,y=e.clientY-12;
16852          var r=el.getBoundingClientRect();
16853          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
16854          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
16855          el.style.left=x+'px';el.style.top=y+'px';
16856        }
16857        return{s:show,h:hide,m:move};
16858      })();
16859      window.rTT=rTT;
16860
16861      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
16862      (function(){
16863        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16864        document.addEventListener('mouseover',function(e){
16865          var t=e.target;
16866          while(t&&t.getAttribute){
16867            var l=t.getAttribute('data-ttl');
16868            if(l!==null){
16869              var v=t.getAttribute('data-ttv')||'';
16870              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
16871              return;
16872            }
16873            t=t.parentNode;
16874          }
16875        });
16876        document.addEventListener('mouseout',function(e){
16877          var t=e.target;
16878          while(t&&t.getAttribute){
16879            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
16880            t=t.parentNode;
16881          }
16882        });
16883        document.addEventListener('mousemove',function(e){
16884          var el=document.getElementById('r-tt');
16885          if(el&&el.style.display!=='none')rTT.m(e);
16886        });
16887      })();
16888
16889      // ── Language overview charts ───────────────────────────────────────────
16890      (function(){
16891        var D={{ lang_chart_json|safe }};
16892        if(!D||!D.length)return;
16893        var el=document.getElementById('result-lang-charts');
16894        if(!el)return;
16895        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16896        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
16897        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16898        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();}
16899        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16900        function px(n){return Math.round(n);}
16901        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+'"';}
16902        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
16903
16904        // Donut chart — height matches the stacked-bar chart so both panels align
16905        var rHb_d=28;
16906        var DH=Math.max(220,D.length*rHb_d+32);
16907        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
16908        var legX=204,DW=360;
16909        var legCount=D.length;
16910        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
16911        var legYStart=Math.round((DH-legCount*legSpacing)/2);
16912        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">';
16913        if(D.length===1){
16914          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
16915          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+'"/>';
16916        } else {
16917          var ang=-Math.PI/2;
16918          D.forEach(function(d,i){
16919            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
16920            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
16921            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
16922            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
16923            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
16924            var pct=Math.round(d.code/tot*100);
16925            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"/>';
16926            ang+=sw;
16927          });
16928        }
16929        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
16930        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
16931        D.forEach(function(d,i){
16932          var ly=legYStart+i*legSpacing;
16933          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
16934          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
16935        });
16936        ds+='</svg>';
16937
16938        // Horizontal stacked-bar chart — fills container width
16939        var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
16940        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
16941        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">';
16942        D.forEach(function(d,i){
16943          var y=6+i*rHb,x=LW;
16944          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
16945          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>';
16946          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;
16947          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;
16948          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"/>';
16949          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>';
16950        });
16951        var ly=SH-14;
16952        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>';
16953        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>';
16954        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>';
16955        bs+='</svg>';
16956        el.innerHTML='<div class="r-lang-overview">'+
16957          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
16958          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
16959        '</div>';
16960      })();
16961
16962      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
16963      (function(){
16964        var LANG_D={{ lang_chart_json|safe }};
16965        var SCAT_D={{ scatter_chart_json|safe }};
16966        var SEM_D={{ semantic_chart_json|safe }};
16967        var SUB_D={{ submodule_chart_json|safe }};
16968        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
16969        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16970        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();}
16971        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16972        function px(n){return Math.round(n);}
16973        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+'"';}
16974
16975        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
16976        function renderCompositionInEl(el,mode,shOvr){
16977          if(!el||!LANG_D||!LANG_D.length)return;
16978          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16979          var LW=110,SH=shOvr||224;
16980          var svgW=Math.max(320,el.offsetWidth||480);
16981          var BW=Math.max(120,svgW-LW-80);
16982          var legendH=24,topPad=4;
16983          var n=LANG_D.length||1;
16984          var rowTotal=Math.floor((SH-legendH-topPad)/n);
16985          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16986          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">';
16987          if(mode==='pct'){
16988            LANG_D.forEach(function(d,i){
16989              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
16990              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
16991              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16992              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>';
16993              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;
16994              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;
16995              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+'"/>';
16996              var pct=Math.round((d.code||0)/tot2*100);
16997              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
16998            });
16999          } else {
17000            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
17001            LANG_D.forEach(function(d,i){
17002              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
17003              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
17004              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>';
17005              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;
17006              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;
17007              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+'"/>';
17008              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>';
17009            });
17010          }
17011          var ly=SH-legendH+4;
17012          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>';
17013          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>';
17014          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>';
17015          s+='</svg>';
17016          el.innerHTML=s;
17017        }
17018        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
17019        renderComposition('abs');
17020        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
17021          btn.addEventListener('click',function(){
17022            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
17023            btn.classList.add('active');
17024            renderComposition(btn.getAttribute('data-rcomp'));
17025          });
17026        });
17027
17028        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
17029        function renderScatterInEl(el,hOvr){
17030          if(!el||!SCAT_D||!SCAT_D.length)return;
17031          var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
17032          var W=Math.max(320,el.offsetWidth||480);
17033          var cW=W-PL-PR,cH=H-PT-PB;
17034          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
17035          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
17036          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
17037          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">';
17038          [0,0.25,0.5,0.75,1].forEach(function(t){
17039            var y=PT+cH*(1-t);
17040            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
17041            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>';
17042          });
17043          [0,0.25,0.5,0.75,1].forEach(function(t){
17044            var x=PL+cW*t;
17045            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
17046            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>';
17047          });
17048          SCAT_D.forEach(function(d,i){
17049            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
17050            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
17051            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"/>';
17052            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>';
17053          });
17054          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>';
17055          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>';
17056          s+='</svg>';
17057          el.innerHTML=s;
17058        }
17059        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
17060
17061        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
17062        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
17063        // the old vertical column layout on wide containers.
17064        function renderSemanticInEl(el,key,sh){
17065          if(!el||!SEM_D||!SEM_D.length)return;
17066          var n2=SEM_D.length||1;
17067          var LW=112,SH=sh||Math.max(180,n2*28+26);
17068          var svgW=Math.max(320,el.offsetWidth||480);
17069          var BW=Math.max(120,svgW-LW-80);
17070          var topPad=4,botPad=14;
17071          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
17072          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
17073          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
17074          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">';
17075          SEM_D.forEach(function(d,i){
17076            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
17077            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>';
17078            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"/>';
17079            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>';
17080          });
17081          s+='</svg>';
17082          el.innerHTML=s;
17083        }
17084        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
17085        var semSel=document.getElementById('r-semantic-metric');
17086        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
17087        var semExpand=document.getElementById('r-semantic-expand');
17088        if(semExpand){
17089          semExpand.addEventListener('click',function(){
17090            var key=semSel?semSel.value:'functions';
17091            var semLabels={'functions':'Functions','classes':'Classes / Types','variables':'Variables'};
17092            var semSubtitle=semLabels[key]||key;
17093            var n=SEM_D.length||1;
17094            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
17095            var modalH=Math.min(Math.max(360,n*38+60),maxH);
17096            var overlay=document.createElement('div');
17097            overlay.className='r-chart-modal-overlay';
17098            overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button><span class="r-chart-modal-title">Semantic Metrics — Full View</span><span class="r-chart-modal-subtitle">'+semSubtitle+'</span><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
17099            document.body.appendChild(overlay);
17100            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
17101            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
17102            var modalEl=document.getElementById('r-sem-modal-chart');
17103            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
17104          });
17105        }
17106
17107        // ── Expand buttons: re-render charts at large size inside modal ──────────
17108        (function(){
17109          function makeExpandModal(title,mH,subtitle){
17110            var overlay=document.createElement('div');
17111            overlay.className='r-chart-modal-overlay';
17112            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
17113            overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button><span class="r-chart-modal-title">'+title+' — Full View</span>'+subHtml+'<div class="r-expand-modal-chart" style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
17114            document.body.appendChild(overlay);
17115            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
17116            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
17117            return overlay.querySelector('.r-expand-modal-chart');
17118          }
17119          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
17120          var compExpandBtn=document.getElementById('r-composition-expand');
17121          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
17122            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
17123            var modeLabel=modeKey==='pct'?'Composition %':'Absolute Lines';
17124            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
17125            var wrap=makeExpandModal('Language Composition',mH,modeLabel);
17126            if(wrap)setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
17127          });}
17128          var scatExpandBtn=document.getElementById('r-scatter-expand');
17129          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
17130            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
17131            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
17132          });}
17133          var densExpandBtn=document.getElementById('r-density-expand');
17134          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
17135            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
17136            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
17137            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
17138          });}
17139          var avgExpandBtn=document.getElementById('r-avglines-expand');
17140          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
17141            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
17142            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
17143            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
17144          });}
17145          var subExpandBtn=document.getElementById('r-submodule-expand');
17146          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
17147            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
17148            var metricLabels={'code':'Code Lines','comment':'Comments','blank':'Blank Lines','physical':'Physical Lines','files':'Files'};
17149            var sortLabels={'desc':'Value ↓','asc':'Value ↑','name':'Name A→Z'};
17150            var subLabel=(metricLabels[key]||key)+' · '+(sortLabels[sort]||sort);
17151            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
17152            var wrap=makeExpandModal('Repository Overview',mH,subLabel);
17153            if(wrap)setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
17154          });}
17155        })();
17156
17157        // ── Comment Density: comments / (code + comments) per language ───────────
17158        function renderDensityInEl(el,shOvr){
17159          if(!el||!LANG_D||!LANG_D.length)return;
17160          var n=LANG_D.length||1;
17161          var LW=112,SH=shOvr||Math.max(180,n*28+26);
17162          var svgW=Math.max(320,el.offsetWidth||480);
17163          var BW=Math.max(120,svgW-LW-80);
17164          var topPad=4,botPad=26;
17165          var rowTotal=Math.floor((SH-topPad-botPad)/n);
17166          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
17167          var densities=LANG_D.map(function(d){
17168            var sig=(d.code||0)+(d.comments||0);
17169            return sig>0?(d.comments||0)/sig:0;
17170          });
17171          var maxDen=Math.max.apply(null,densities)||1;
17172          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">';
17173          LANG_D.forEach(function(d,i){
17174            var den=densities[i],bw=den/maxDen*BW;
17175            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
17176            var pct=Math.round(den*100);
17177            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>';
17178            if(bw>0.5)s+='<rect'+tt(d.lang,pct+'% of significant lines are comments')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
17179            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17180            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+pct+'%</text>';
17181          });
17182          s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" fill="currentColor" opacity="0.75">comment ratio (higher = more documented)</text>';
17183          s+='</svg>';
17184          el.innerHTML=s;
17185        }
17186        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
17187        renderDensity();
17188
17189        // ── Avg Lines per File: code / files per language ─────────────────────
17190        function renderAvgLinesInEl(el,shOvr){
17191          if(!el||!LANG_D||!LANG_D.length)return;
17192          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
17193          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
17194          var n=data.length||1;
17195          var LW=112,SH=shOvr||Math.max(180,n*28+26);
17196          var svgW=Math.max(320,el.offsetWidth||480);
17197          var BW=Math.max(120,svgW-LW-80);
17198          var topPad=4,botPad=26;
17199          var rowTotal=Math.floor((SH-topPad-botPad)/n);
17200          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
17201          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
17202          var maxAvg=Math.max.apply(null,avgs)||1;
17203          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">';
17204          data.forEach(function(d,i){
17205            var avg=avgs[i],bw=avg/maxAvg*BW;
17206            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
17207            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>';
17208            if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file · '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
17209            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17210            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(Math.round(avg))+'</text>';
17211          });
17212          s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" fill="currentColor" opacity="0.75">avg code lines per file (higher = larger files)</text>';
17213          s+='</svg>';
17214          el.innerHTML=s;
17215        }
17216        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
17217        renderAvgLines();
17218
17219        // ── Repository Overview: overall row + per-submodule rows ────────────
17220        function renderSubmoduleInEl(el,key,sort,shOvr){
17221          if(!el)return;
17222          var overall={
17223            name:'Overall',
17224            code:LANG_D.reduce(function(s,d){return s+(d.code||0);},0),
17225            comment:LANG_D.reduce(function(s,d){return s+(d.comments||0);},0),
17226            blank:LANG_D.reduce(function(s,d){return s+(d.blanks||0);},0),
17227            physical:SCAT_D.reduce(function(s,d){return s+(d.physical||0);},0),
17228            files:LANG_D.reduce(function(s,d){return s+(d.files||0);},0),
17229            isOverall:true
17230          };
17231          var subs=SUB_D.slice();
17232          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
17233          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
17234          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
17235          var data=[overall].concat(subs);
17236          var rowH=32,bH=22,sepH=subs.length>0?14:0;
17237          var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
17238          var svgW=Math.max(320,el.offsetWidth||480);
17239          var LW=116,BW=Math.max(200,svgW-LW-54);
17240          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
17241          var OVERALL_COL='#6b7280';
17242          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">';
17243          var yOff=4;
17244          data.forEach(function(d,i){
17245            var v=d[key]||0,bw=v/maxV*BW,y=yOff;
17246            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
17247            var label=d.name||d.path||'?';
17248            s+='<text x="'+(LW-5)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
17249            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
17250            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17251            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;"'+(d.isOverall?' font-weight="700"':'')+'>'+fmt(v)+'</text>';
17252            yOff+=rowH;
17253            if(d.isOverall&&subs.length>0){
17254              yOff+=sepH;
17255            }
17256          });
17257          s+='</svg>';
17258          el.innerHTML=s;
17259        }
17260        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
17261        var subSel=document.getElementById('r-sub-metric');
17262        var sortSel=document.getElementById('r-sub-sort');
17263        renderSubmodule('code','desc');
17264        if(subSel){
17265          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
17266          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
17267        }
17268
17269        // Equalise heights within each chart row: if one chart in a grid row is taller
17270        // than its neighbour, re-render the shorter one at the taller height so bars fill
17271        // the available vertical space instead of leaving a gap.
17272        function syncRowHeights(){
17273          var avgEl=document.getElementById('r-avglines-chart');
17274          var subEl=document.getElementById('r-submodule-chart');
17275          if(avgEl&&subEl){
17276            var avgSvg=avgEl.querySelector('svg');
17277            var subSvg=subEl.querySelector('svg');
17278            if(avgSvg&&subSvg){
17279              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
17280              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
17281              var key=subSel?subSel.value||'code':'code';
17282              var sort=sortSel?sortSel.value:'desc';
17283              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
17284              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
17285            }
17286          }
17287          var semEl=document.getElementById('r-semantic-chart');
17288          var denEl=document.getElementById('r-density-chart');
17289          if(semEl&&denEl){
17290            var semSvg=semEl.querySelector('svg');
17291            var denSvg=denEl.querySelector('svg');
17292            if(semSvg&&denSvg){
17293              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
17294              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
17295              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
17296              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
17297            }
17298          }
17299        }
17300        syncRowHeights();
17301
17302        // Re-render all SVG charts when the window is resized so bars fill the card.
17303        var _rResizeTimer;
17304        window.addEventListener('resize',function(){
17305          clearTimeout(_rResizeTimer);
17306          _rResizeTimer=setTimeout(function(){
17307            var rcompBtn=document.querySelector('[data-rcomp].active');
17308            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
17309            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
17310            if(semSel)renderSemantic(semSel.value||'functions');
17311            renderDensity();
17312            renderAvgLines();
17313            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
17314            syncRowHeights();
17315          },120);
17316        });
17317      })();
17318
17319      (function randomizeWatermarks() {
17320        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
17321        if (!wms.length) return;
17322        var placed = [];
17323        function tooClose(top, left) {
17324          for (var i = 0; i < placed.length; i++) {
17325            var dt = Math.abs(placed[i][0] - top);
17326            var dl = Math.abs(placed[i][1] - left);
17327            if (dt < 20 && dl < 18) return true;
17328          }
17329          return false;
17330        }
17331        function pick(leftBand) {
17332          for (var attempt = 0; attempt < 50; attempt++) {
17333            var top = Math.random() * 85 + 5;
17334            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
17335            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
17336          }
17337          var top = Math.random() * 85 + 5;
17338          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
17339          placed.push([top, left]);
17340          return [top, left];
17341        }
17342        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
17343        var half = Math.floor(wms.length / 2);
17344        wms.forEach(function (img, i) {
17345          var pos = pick(i < half);
17346          var size = Math.floor(Math.random() * 100 + 160);
17347          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
17348          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
17349          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;
17350        });
17351      })();
17352
17353      (function spawnCodeParticles() {
17354        var container = document.getElementById('code-particles');
17355        if (!container) return;
17356        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'];
17357        for (var i = 0; i < 38; i++) {
17358          (function(idx) {
17359            var el = document.createElement('span');
17360            el.className = 'code-particle';
17361            el.textContent = snippets[idx % snippets.length];
17362            var left = Math.random() * 94 + 2;
17363            var top = Math.random() * 88 + 6;
17364            var dur = (Math.random() * 10 + 9).toFixed(1);
17365            var delay = (Math.random() * 18).toFixed(1);
17366            var rot = (Math.random() * 26 - 13).toFixed(1);
17367            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17368            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';
17369            container.appendChild(el);
17370          })(i);
17371        }
17372      })();
17373
17374      {% if pdf_generating %}
17375      // Poll for PDF readiness and swap the disabled button to a live link once done.
17376      (function() {
17377        var openBtn = document.getElementById('pdf-open-btn');
17378        var dlBtn = document.getElementById('pdf-download-btn');
17379        function checkPdf() {
17380          fetch('/api/runs/{{ run_id }}/pdf-status')
17381            .then(function(r) { return r.json(); })
17382            .then(function(d) {
17383              if (d.ready) {
17384                if (openBtn) {
17385                  var a = document.createElement('a');
17386                  a.className = 'button';
17387                  a.id = 'pdf-open-btn';
17388                  a.href = '/runs/pdf/{{ run_id }}';
17389                  a.target = '_blank';
17390                  a.rel = 'noopener';
17391                  a.textContent = 'Open PDF';
17392                  openBtn.replaceWith(a);
17393                }
17394                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
17395              } else {
17396                setTimeout(checkPdf, 3000);
17397              }
17398            })
17399            .catch(function() { setTimeout(checkPdf, 5000); });
17400        }
17401        setTimeout(checkPdf, 3000);
17402      })();
17403      {% endif %}
17404
17405    })();
17406  </script>
17407  <script nonce="{{ csp_nonce }}">
17408  (function(){
17409    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'}];
17410    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);});}
17411    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17412    function init(){
17413      var btn=document.getElementById('settings-btn');if(!btn)return;
17414      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17415      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>';
17416      document.body.appendChild(m);
17417      var g=document.getElementById('scheme-grid');
17418      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);});
17419      var cl=document.getElementById('settings-close');
17420      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);
17421      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');});
17422      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17423      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17424    }
17425    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17426  }());
17427  </script>
17428  <footer class="site-footer">
17429    local code analysis - metrics, history and reports
17430    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17431    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17432    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17433    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17434    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17435  </footer>
17436  {% if confluence_configured %}
17437  <script nonce="{{ csp_nonce }}">
17438  (function() {
17439    var postBtn = document.getElementById('postConfluenceBtn');
17440    var copyBtn = document.getElementById('copyWikiBtn');
17441    var modal   = document.getElementById('confluenceModal');
17442    if (!postBtn || !modal) return;
17443
17444    postBtn.addEventListener('click', function() {
17445      document.getElementById('confStatus').style.display = 'none';
17446      modal.style.display = 'flex';
17447    });
17448    document.getElementById('confCancelBtn').addEventListener('click', function() {
17449      modal.style.display = 'none';
17450    });
17451    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17452
17453    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
17454      var btn = this;
17455      btn.disabled = true;
17456      var status = document.getElementById('confStatus');
17457      status.style.display = 'block';
17458      status.style.background = '#dbeafe';
17459      status.style.color = '#1e40af';
17460      status.textContent = 'Posting to Confluence…';
17461      var resp = await fetch('/api/confluence/post', {
17462        method: 'POST',
17463        headers: { 'Content-Type': 'application/json' },
17464        body: JSON.stringify({
17465          run_id: '{{ run_id }}',
17466          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
17467          report_url: document.getElementById('confReportUrl').value.trim() || null
17468        })
17469      });
17470      var data = await resp.json();
17471      if (data.ok) {
17472        status.style.background = '#dcfce7'; status.style.color = '#166534';
17473        status.textContent = 'Posted! Page ID: ' + data.page_id;
17474      } else {
17475        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17476        status.textContent = 'Error: ' + (data.error || 'Unknown error');
17477      }
17478      btn.disabled = false;
17479    });
17480
17481    if (copyBtn) {
17482      copyBtn.addEventListener('click', async function() {
17483        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
17484        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
17485        var text = await resp.text();
17486        try {
17487          await navigator.clipboard.writeText(text);
17488          var orig = copyBtn.textContent;
17489          copyBtn.textContent = 'Copied!';
17490          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
17491        } catch(e) {
17492          alert('Clipboard write failed — check browser permissions.');
17493        }
17494      });
17495    }
17496  })();
17497  </script>
17498  {% endif %}
17499  <script nonce="{{ csp_nonce }}">
17500  (function() {
17501    var deleteBtn = document.getElementById('delete-run-btn');
17502    var modal     = document.getElementById('delete-run-modal');
17503    var cancelBtn = document.getElementById('delete-run-cancel');
17504    var confirmBtn= document.getElementById('delete-run-confirm');
17505    if (!deleteBtn || !modal) return;
17506    deleteBtn.addEventListener('click', function() {
17507      document.getElementById('delete-run-status').style.display = 'none';
17508      modal.style.display = 'flex';
17509    });
17510    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
17511    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17512    confirmBtn.addEventListener('click', async function() {
17513      confirmBtn.disabled = true;
17514      cancelBtn.disabled = true;
17515      var status = document.getElementById('delete-run-status');
17516      status.style.display = 'block';
17517      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
17518      status.textContent = 'Deleting…';
17519      try {
17520        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
17521        if (resp.status === 204 || resp.ok) {
17522          status.style.background = '#dcfce7'; status.style.color = '#166534';
17523          status.textContent = 'Deleted. Redirecting…';
17524          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
17525        } else {
17526          var d = await resp.json().catch(function(){return {};});
17527          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17528          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
17529          confirmBtn.disabled = false;
17530          cancelBtn.disabled = false;
17531        }
17532      } catch (e) {
17533        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17534        status.textContent = 'Network error: ' + String(e);
17535        confirmBtn.disabled = false;
17536        cancelBtn.disabled = false;
17537      }
17538    });
17539  })();
17540  </script>
17541  <script nonce="{{ csp_nonce }}">(function(){
17542    var bundleBtn = document.getElementById('download-bundle-btn');
17543    if (bundleBtn) {
17544      bundleBtn.addEventListener('click', function() {
17545        bundleBtn.disabled = true;
17546        var orig = bundleBtn.textContent;
17547        bundleBtn.textContent = 'Preparing…';
17548        fetch('/api/runs/{{ run_id }}/bundle')
17549          .then(function(r) {
17550            if (!r.ok) throw new Error('HTTP ' + r.status);
17551            return r.blob();
17552          })
17553          .then(function(blob) {
17554            var url = URL.createObjectURL(blob);
17555            var a = document.createElement('a');
17556            a.href = url;
17557            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
17558            document.body.appendChild(a);
17559            a.click();
17560            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
17561            bundleBtn.disabled = false;
17562            bundleBtn.textContent = orig;
17563          })
17564          .catch(function(e) {
17565            bundleBtn.disabled = false;
17566            bundleBtn.textContent = orig;
17567            alert('Bundle download failed: ' + String(e));
17568          });
17569      });
17570    }
17571  })();</script>
17572  <script nonce="{{ csp_nonce }}">(function(){
17573    var dot=document.getElementById('status-dot');
17574    var pingEl=document.getElementById('server-ping-ms');
17575    var tipEl=document.getElementById('server-tip-ping');
17576    var fm=document.getElementById('footer-mode');
17577    function setDotColor(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}
17578    function doPing(){
17579      var t0=performance.now();
17580      fetch('/healthz',{cache:'no-store'})
17581        .then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDotColor(ms);})
17582        .catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});
17583    }
17584    doPing();
17585    setInterval(doPing,5000);
17586    if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
17587  })();</script>
17588  {% if let Some(banner) = report_header_footer %}
17589  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
17590  {% endif %}
17591</body>
17592</html>
17593"##,
17594    ext = "html"
17595)]
17596// Template structs need many bool fields to pass Askama rendering flags.
17597#[allow(clippy::struct_excessive_bools)]
17598struct ResultTemplate {
17599    version: &'static str,
17600    report_title: String,
17601    project_path: String,
17602    output_dir: String,
17603    run_id: String,
17604    files_analyzed: u64,
17605    files_skipped: u64,
17606    physical_lines: u64,
17607    code_lines: u64,
17608    comment_lines: u64,
17609    blank_lines: u64,
17610    mixed_lines: u64,
17611    functions: u64,
17612    classes: u64,
17613    variables: u64,
17614    imports: u64,
17615    html_url: Option<String>,
17616    pdf_url: Option<String>,
17617    json_url: Option<String>,
17618    html_download_url: Option<String>,
17619    pdf_download_url: Option<String>,
17620    json_download_url: Option<String>,
17621    html_path: Option<String>,
17622    json_path: Option<String>,
17623    prev_run_id: Option<String>,
17624    prev_run_timestamp: Option<String>,
17625    prev_run_code_lines: Option<u64>,
17626    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
17627    prev_fa_str: String,
17628    prev_fs_str: String,
17629    prev_pl_str: String,
17630    prev_cl_str: String,
17631    prev_cml_str: String,
17632    prev_bl_str: String,
17633    // Signed change column for main metrics
17634    delta_fa_str: String,
17635    delta_fa_class: String,
17636    delta_fs_str: String,
17637    delta_fs_class: String,
17638    delta_pl_str: String,
17639    delta_pl_class: String,
17640    delta_cl_str: String,
17641    delta_cl_class: String,
17642    delta_cml_str: String,
17643    delta_cml_class: String,
17644    delta_bl_str: String,
17645    delta_bl_class: String,
17646    // delta vs previous scan
17647    delta_lines_added: Option<i64>,
17648    delta_lines_removed: Option<i64>,
17649    delta_lines_net_str: String,
17650    delta_lines_net_class: String,
17651    delta_files_added: Option<usize>,
17652    delta_files_removed: Option<usize>,
17653    delta_files_modified: Option<usize>,
17654    delta_files_unchanged: Option<usize>,
17655    delta_unmodified_lines: Option<u64>,
17656    // git context
17657    git_branch: Option<String>,
17658    git_commit: Option<String>,
17659    git_commit_long: Option<String>,
17660    git_author: Option<String>,
17661    git_commit_url: Option<String>,
17662    // scan metadata for hero section
17663    scan_performed_by: String,
17664    scan_time_display: String,
17665    os_display: String,
17666    test_count: u64,
17667    // history
17668    prev_scan_count: usize,
17669    current_scan_number: usize,
17670    // submodule breakdown (empty when not requested)
17671    submodule_rows: Vec<SubmoduleRow>,
17672    scan_config_url: String,
17673    lang_chart_json: String,
17674    // Askama reads these via proc-macro expansion; clippy can't trace through it.
17675    #[allow(dead_code)]
17676    scatter_chart_json: String,
17677    #[allow(dead_code)]
17678    semantic_chart_json: String,
17679    #[allow(dead_code)]
17680    submodule_chart_json: String,
17681    #[allow(dead_code)]
17682    has_submodule_data: bool,
17683    #[allow(dead_code)]
17684    has_semantic_data: bool,
17685    pdf_generating: bool,
17686    csp_nonce: String,
17687    /// Whether Confluence integration is configured — shows Post button when true.
17688    confluence_configured: bool,
17689    server_mode: bool,
17690    /// Header/footer identification banner, mirrored from the HTML/PDF report.
17691    report_header_footer: Option<String>,
17692    run_id_short: String,
17693}
17694
17695#[derive(Template)]
17696#[template(
17697    source = r##"
17698<!doctype html>
17699<html lang="en">
17700<head>
17701  <meta charset="utf-8">
17702  <meta name="viewport" content="width=device-width, initial-scale=1">
17703  <title>OxideSLOC | Analyzing…</title>
17704  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17705  <style nonce="{{ csp_nonce }}">
17706    :root {
17707      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17708      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17709      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17710      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17711    }
17712    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17713    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
17714    .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);}
17715    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17716    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
17717    .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));}
17718    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17719    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17720    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17721    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17722    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17723    @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; } }
17724    .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;}
17725    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17726    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17727    .page-body{padding:32px 24px 36px;}
17728    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
17729    .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;}
17730    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
17731    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
17732    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
17733    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
17734    .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;}
17735    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
17736    .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;}
17737    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
17738    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
17739    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
17740    .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;}
17741    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
17742    .hidden{display:none!important;}
17743    .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;}
17744    .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;}
17745    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
17746    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
17747    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
17748    .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);}
17749    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
17750    .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;}
17751    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
17752    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17753    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17754    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
17755    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17756    .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;}
17757    @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));}}
17758    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17759    .site-footer a{color:var(--muted);}
17760    .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;}
17761    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
17762    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
17763    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
17764  </style>
17765</head>
17766<body>
17767  <div class="background-watermarks" aria-hidden="true">
17768    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17769    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17770    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17771    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17772    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17773    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17774  </div>
17775  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17776  <nav class="top-nav">
17777    <div class="top-nav-inner">
17778      <a href="/" class="brand">
17779        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
17780        <div class="brand-copy">
17781          <h1 class="brand-title">OxideSLOC</h1>
17782          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17783        </div>
17784      </a>
17785      <div class="nav-right">
17786        <a class="nav-pill" href="/">Home</a>
17787        <div class="nav-dropdown">
17788          <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>
17789          <div class="nav-dropdown-menu">
17790            <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>
17791          </div>
17792        </div>
17793        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17794        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17795        <div class="nav-dropdown">
17796          <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>
17797          <div class="nav-dropdown-menu">
17798            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
17799          </div>
17800        </div>
17801        <div class="server-status-wrap" id="server-status-wrap">
17802          <div class="nav-pill server-online-pill" id="server-status-pill">
17803            <span class="status-dot" id="status-dot"></span>
17804            <span id="server-status-label">Server</span>
17805            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17806          </div>
17807          <div class="server-status-tip">
17808            OxideSLOC is running — accessible on your network.
17809            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17810          </div>
17811        </div>
17812        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17813          <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>
17814        </button>
17815        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17816          <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>
17817          <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>
17818        </button>
17819      </div>
17820    </div>
17821  </nav>
17822  <div class="page-body">
17823    <div class="wait-panel">
17824      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
17825      <h2 class="wait-title">Analyzing your project…</h2>
17826      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
17827      <div class="path-block">{{ project_path }}</div>
17828      <div class="metrics-row">
17829        <div class="metric-card">
17830          <div class="metric-label">Elapsed</div>
17831          <div class="metric-value" id="elapsed">0s</div>
17832        </div>
17833        <div class="metric-card">
17834          <div class="metric-label">Phase</div>
17835          <div class="metric-value" id="phase">Starting</div>
17836        </div>
17837        <div class="metric-card hidden" id="files-card">
17838          <div class="metric-label">Files</div>
17839          <div class="metric-value" id="files-progress">0</div>
17840        </div>
17841      </div>
17842      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
17843      <div class="warn-slow hidden" id="warn-slow">
17844        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.
17845      </div>
17846      <div class="err-panel hidden" id="err-panel">
17847        <strong>Analysis failed</strong>
17848        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
17849      </div>
17850      <div class="actions hidden" id="actions">
17851        <a href="/scan" class="btn-primary">Try Again</a>
17852        <a href="/view-reports" class="btn-outline">View Reports</a>
17853      </div>
17854    </div>
17855  </div>
17856  <script nonce="{{ csp_nonce }}">
17857    (function() {
17858      var WAIT_ID = {{ wait_id_json|safe }};
17859      var startTime = Date.now();
17860      var pollInterval = 1500;
17861      var retries = 0;
17862      var maxRetries = 5;
17863      var warnShown = false;
17864
17865      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();}
17866
17867      function elapsed() {
17868        return Math.floor((Date.now() - startTime) / 1000);
17869      }
17870
17871      function updateElapsed() {
17872        var s = elapsed();
17873        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
17874      }
17875
17876      function setPhase(txt) {
17877        document.getElementById('phase').textContent = txt;
17878      }
17879
17880      var elapsedTimer = setInterval(updateElapsed, 1000);
17881
17882      function poll() {
17883        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
17884          .then(function(r) {
17885            if (!r.ok) throw new Error('HTTP ' + r.status);
17886            return r.json();
17887          })
17888          .then(function(data) {
17889            retries = 0;
17890            if (data.state === 'complete') {
17891              clearInterval(elapsedTimer);
17892              setPhase('Done');
17893              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
17894            } else if (data.state === 'failed') {
17895              clearInterval(elapsedTimer);
17896              setPhase('Failed');
17897              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
17898              document.getElementById('err-panel').classList.remove('hidden');
17899              document.getElementById('actions').classList.remove('hidden');
17900            } else {
17901              // still running
17902              var s = elapsed();
17903              if (s > 90 && !warnShown) {
17904                warnShown = true;
17905                document.getElementById('warn-slow').classList.remove('hidden');
17906              }
17907              setPhase(data.phase || 'Running');
17908              var fd = data.files_done || 0, ft = data.files_total || 0;
17909              if (ft > 0) {
17910                var card = document.getElementById('files-card');
17911                if (card) card.classList.remove('hidden');
17912                var fp = document.getElementById('files-progress');
17913                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
17914              }
17915              setTimeout(poll, pollInterval);
17916            }
17917          })
17918          .catch(function(err) {
17919            retries++;
17920            if (retries >= maxRetries) {
17921              clearInterval(elapsedTimer);
17922              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
17923              document.getElementById('err-panel').classList.remove('hidden');
17924              document.getElementById('actions').classList.remove('hidden');
17925            } else {
17926              // exponential back-off capped at 8s
17927              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
17928            }
17929          });
17930      }
17931
17932      setTimeout(poll, pollInterval);
17933    })();
17934  </script>
17935  <footer class="site-footer">
17936    local code analysis - metrics, history and reports
17937    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
17938    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17939    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17940    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17941    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17942  </footer>
17943  <script nonce="{{ csp_nonce }}">
17944    (function(){
17945      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
17946      if(s==="dark")b.classList.add("dark-theme");
17947      var tt=document.getElementById("theme-toggle");
17948      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
17949    })();
17950    (function spawnCodeParticles(){
17951      var c=document.getElementById('code-particles');if(!c)return;
17952      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'];
17953      for(var i=0;i<32;i++){(function(idx){
17954        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
17955        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
17956        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
17957        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
17958        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
17959        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
17960        c.appendChild(el);
17961      })(i);}
17962    })();
17963    (function randomizeWatermarks(){
17964      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17965      var placed=[];
17966      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;}
17967      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];}
17968      var half=Math.floor(wms.length/2);
17969      wms.forEach(function(img,i){
17970        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
17971        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
17972        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
17973        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
17974        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
17975        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
17976      });
17977    })();
17978  </script>
17979  <script nonce="{{ csp_nonce }}">
17980  (function(){
17981    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'}];
17982    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);});}
17983    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17984    function init(){
17985      var btn=document.getElementById('settings-btn');if(!btn)return;
17986      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17987      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>';
17988      document.body.appendChild(m);
17989      var g=document.getElementById('scheme-grid');
17990      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);});
17991      var cl=document.getElementById('settings-close');
17992      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);
17993      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');});
17994      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17995      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17996    }
17997    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17998  }());
17999  </script>
18000  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
18001</body>
18002</html>
18003"##,
18004    ext = "html"
18005)]
18006struct ScanWaitTemplate {
18007    version: &'static str,
18008    wait_id_json: String,
18009    project_path: String,
18010    csp_nonce: String,
18011}
18012
18013#[derive(Template)]
18014#[template(
18015    source = r##"
18016<!doctype html>
18017<html lang="en">
18018<head>
18019  <meta charset="utf-8">
18020  <meta name="viewport" content="width=device-width, initial-scale=1">
18021  <title>OxideSLOC | Error</title>
18022  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18023  <style nonce="{{ csp_nonce }}">
18024    :root {
18025      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18026      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18027      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
18028      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18029    }
18030    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18031    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
18032    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18033    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18034    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
18035    .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);}
18036    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18037    .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));}
18038    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18039    .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;}
18040    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18041    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18042    @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; } }
18043    .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;}
18044    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18045    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18046    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18047    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18048    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18049    .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;}
18050    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18051    .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);}
18052    .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;}
18053    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18054    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18055    .settings-modal-body{padding:14px 16px 16px;}
18056    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18057    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18058    .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;}
18059    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18060    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18061    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18062    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18063    .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;}
18064    .tz-select:focus{border-color:var(--oxide);}
18065    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
18066    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
18067    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
18068    .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;}
18069    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
18070    .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);}
18071    .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;}
18072    .btn-secondary:hover{background:var(--line);}
18073    .bug-report-wrap{margin-top:22px;border-top:1px solid var(--line);padding-top:16px;}
18074    .bug-report-wrap summary{cursor:pointer;font-size:12px;font-weight:700;color:var(--muted);list-style:none;display:inline-flex;align-items:center;gap:6px;user-select:none;padding:2px 0;}
18075    .bug-report-wrap summary::-webkit-details-marker{display:none;}
18076    .bug-report-arrow{display:inline-block;font-size:9px;transition:transform .15s ease;}
18077    .bug-report-wrap[open] .bug-report-arrow{transform:rotate(90deg);}
18078    .bug-report-wrap summary:hover{color:var(--text);}
18079    .bug-report-body{margin-top:12px;display:flex;flex-direction:column;gap:10px;}
18080    .bug-report-pre{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 16px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.65;color:var(--text);white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow-y:auto;}
18081    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
18082    .btn-sm{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:0 12px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;text-decoration:none;transition:background .15s ease;}
18083    .btn-sm:hover{background:var(--line);}
18084    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
18085    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
18086    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
18087    .bug-report-hint a:hover{text-decoration:underline;}
18088    .site-footer{margin-top:auto;padding:16px 24px;text-align:center;font-size:11px;color:var(--muted);border-top:1px solid var(--line);position:relative;z-index:1;}
18089    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
18090    .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;}
18091    .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;}
18092    .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;}
18093    @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));}}
18094    .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;}
18095  </style>
18096</head>
18097<body>
18098  <div class="background-watermarks" aria-hidden="true">
18099    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18100    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18101    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18102    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18103    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18104    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18105  </div>
18106  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18107  <div class="top-nav">
18108    <div class="top-nav-inner">
18109      <a class="brand" href="/">
18110        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18111        <div class="brand-copy">
18112          <div class="brand-title">OxideSLOC</div>
18113          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18114        </div>
18115      </a>
18116      <div class="nav-right">
18117        <a class="nav-pill" href="/">Home</a>
18118        <div class="nav-dropdown">
18119          <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>
18120          <div class="nav-dropdown-menu">
18121            <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>
18122          </div>
18123        </div>
18124        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18125        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18126        <div class="nav-dropdown">
18127          <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>
18128          <div class="nav-dropdown-menu">
18129            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
18130          </div>
18131        </div>
18132        <div class="server-status-wrap" id="server-status-wrap">
18133          <div class="nav-pill server-online-pill" id="server-status-pill">
18134            <span class="status-dot" id="status-dot"></span>
18135            <span id="server-status-label">Server</span>
18136            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18137          </div>
18138          <div class="server-status-tip">
18139            OxideSLOC is running — accessible on your network.
18140            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18141          </div>
18142        </div>
18143        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18144          <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>
18145        </button>
18146        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18147          <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>
18148          <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>
18149        </button>
18150      </div>
18151    </div>
18152  </div>
18153
18154  <div class="page">
18155    <div class="panel">
18156      <h1>Error</h1>
18157      <div class="error-box" id="error-msg-text">{{ message }}</div>
18158      <div id="br-meta" hidden
18159        data-version="{{ version }}"
18160        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
18161        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
18162      <div class="actions">
18163        <a class="btn-primary" href="/scan">Back to setup</a>
18164        {% if let Some(report_url) = last_report_url %}
18165        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
18166        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
18167        {% else %}
18168        <a class="btn-secondary" href="/view-reports">View Reports</a>
18169        {% endif %}
18170      </div>
18171      <details class="bug-report-wrap" id="bug-report-wrap">
18172        <summary><span class="bug-report-arrow">&#9658;</span>&nbsp;Generate bug report</summary>
18173        <div class="bug-report-body">
18174          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
18175          <div class="bug-report-btns">
18176            <button type="button" class="btn-sm" id="bug-report-copy">
18177              <svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
18178              Copy to clipboard
18179            </button>
18180            <a class="btn-sm" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer">
18181              <svg viewBox="0 0 24 24"><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>
18182              Open GitHub Issue
18183            </a>
18184          </div>
18185          <p class="bug-report-hint">Copy the report above and paste it into a new GitHub issue. Remove any file paths or project names you prefer not to share before posting.</p>
18186        </div>
18187      </details>
18188    </div>
18189  </div>
18190  <footer class="site-footer">
18191    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
18192    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18193    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18194    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18195    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
18196  </footer>
18197  <script nonce="{{ csp_nonce }}">(function(){
18198    var meta=document.getElementById('br-meta');
18199    var pre=document.getElementById('bug-report-pre');
18200    var copyBtn=document.getElementById('bug-report-copy');
18201    if(!meta||!pre)return;
18202    var ver=meta.getAttribute('data-version')||'';
18203    var runId=meta.getAttribute('data-run-id')||'';
18204    var code=meta.getAttribute('data-error-code')||'';
18205    var msgEl=document.getElementById('error-msg-text');
18206    var msg=msgEl?msgEl.textContent.trim():'';
18207    function getBrowser(){
18208      var ua=navigator.userAgent;
18209      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
18210      if(!m)return 'Unknown browser';
18211      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
18212      return n+' '+m[2];
18213    }
18214    var lines=['oxide-sloc Bug Report','==============================',''];
18215    lines.push('App version:  v'+ver);
18216    if(code)lines.push('HTTP status:  '+code);
18217    if(runId)lines.push('Run ID:       '+runId);
18218    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
18219    lines.push('Timestamp:    '+new Date().toISOString());
18220    lines.push('Browser:      '+getBrowser());
18221    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
18222    lines.push('');
18223    lines.push('Error message:');
18224    lines.push(msg);
18225    lines.push('');
18226    lines.push('Steps to reproduce:');
18227    lines.push('  1. ');
18228    lines.push('');
18229    lines.push('Expected behavior:');
18230    lines.push('  ');
18231    pre.textContent=lines.join('\n');
18232    if(copyBtn){
18233      copyBtn.addEventListener('click',function(){
18234        var txt=pre.textContent;
18235        if(navigator.clipboard&&navigator.clipboard.writeText){
18236          navigator.clipboard.writeText(txt).then(function(){
18237            copyBtn.textContent='[OK] Copied!';
18238            setTimeout(function(){copyBtn.innerHTML='<svg viewBox="0 0 24 24" style="width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy to clipboard';},2000);
18239          });
18240        }else{
18241          var ta=document.createElement('textarea');
18242          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
18243          document.body.appendChild(ta);ta.select();
18244          try{document.execCommand('copy');copyBtn.textContent='[OK] Copied!';}catch(e){}
18245          document.body.removeChild(ta);
18246        }
18247      });
18248    }
18249  })();</script>
18250  <script nonce="{{ csp_nonce }}">
18251    (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");});})();
18252    (function spawnCodeParticles() {
18253      var container = document.getElementById('code-particles');
18254      if (!container) return;
18255      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'];
18256      for (var i = 0; i < 38; i++) {
18257        (function(idx) {
18258          var el = document.createElement('span');
18259          el.className = 'code-particle';
18260          el.textContent = snippets[idx % snippets.length];
18261          var left = Math.random() * 94 + 2;
18262          var top = Math.random() * 88 + 6;
18263          var dur = (Math.random() * 10 + 9).toFixed(1);
18264          var delay = (Math.random() * 18).toFixed(1);
18265          var rot = (Math.random() * 26 - 13).toFixed(1);
18266          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18267          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';
18268          container.appendChild(el);
18269        })(i);
18270      }
18271    })();
18272    (function randomizeWatermarks() {
18273      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18274      var placed = [];
18275      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; }
18276      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]; }
18277      var half = Math.floor(wms.length/2);
18278      wms.forEach(function(img, i) {
18279        var pos = pick(i < half);
18280        var w = Math.floor(Math.random()*60+80);
18281        var rot = (Math.random()*40-20).toFixed(1);
18282        var op = (Math.random()*0.08+0.05).toFixed(2);
18283        var animDur = (Math.random()*6+5).toFixed(1);
18284        var animDelay = (Math.random()*10).toFixed(1);
18285        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';
18286      });
18287    })();
18288  </script>
18289  <script nonce="{{ csp_nonce }}">
18290  (function(){
18291    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'}];
18292    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);});}
18293    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18294    function init(){
18295      var btn=document.getElementById('settings-btn');if(!btn)return;
18296      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18297      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>';
18298      document.body.appendChild(m);
18299      var g=document.getElementById('scheme-grid');
18300      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);});
18301      var cl=document.getElementById('settings-close');
18302      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);
18303      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');});
18304      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18305      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18306    }
18307    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18308  }());
18309  </script>
18310  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
18311</body>
18312</html>
18313"##,
18314    ext = "html"
18315)]
18316struct ErrorTemplate {
18317    message: String,
18318    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
18319    last_report_url: Option<String>,
18320    /// Label for the secondary action button; defaults to "View last report" when None.
18321    last_report_label: Option<String>,
18322    /// Run ID to surface in the bug report; `None` when not applicable.
18323    run_id: Option<String>,
18324    /// HTTP status code to surface in the bug report; `None` when unknown.
18325    error_code: Option<u16>,
18326    csp_nonce: String,
18327    version: &'static str,
18328}
18329
18330// ── RelocateScanTemplate ──────────────────────────────────────────────────────
18331
18332#[derive(Template)]
18333#[template(
18334    source = r##"
18335<!doctype html>
18336<html lang="en">
18337<head>
18338  <meta charset="utf-8">
18339  <meta name="viewport" content="width=device-width, initial-scale=1">
18340  <title>OxideSLOC | Locate Scan Files</title>
18341  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18342  <style nonce="{{ csp_nonce }}">
18343    :root {
18344      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18345      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18346      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
18347      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18348    }
18349    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18350    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
18351    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18352    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18353    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
18354    .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);}
18355    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18356    .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));}
18357    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18358    .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;}
18359    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18360    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
18361    @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;}}
18362    .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;}
18363    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18364    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18365    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18366    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18367    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18368    .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;}
18369    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18370    .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);}
18371    .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;}
18372    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18373    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18374    .settings-modal-body{padding:14px 16px 16px;}
18375    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18376    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18377    .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;}
18378    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18379    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18380    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18381    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18382    .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;}
18383    .tz-select:focus{border-color:var(--oxide);}
18384    .page{max-width:860px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
18385    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
18386    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
18387    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
18388    .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;}
18389    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
18390    .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;}
18391    .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;}
18392    .btn-secondary:hover{background:var(--line);}
18393    .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;}
18394    .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;}
18395    .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;}
18396    @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));}}
18397    .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;}
18398    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
18399    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
18400    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
18401    .relocate-row{display:flex;gap:8px;align-items:stretch;}
18402    .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;}
18403    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
18404    body.dark-theme .relocate-input{background:var(--surface-2);}
18405  </style>
18406</head>
18407<body>
18408  <div class="background-watermarks" aria-hidden="true">
18409    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18410    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18411    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18412    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18413    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18414    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18415  </div>
18416  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18417  <div class="top-nav">
18418    <div class="top-nav-inner">
18419      <a class="brand" href="/">
18420        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18421        <div class="brand-copy">
18422          <div class="brand-title">OxideSLOC</div>
18423          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18424        </div>
18425      </a>
18426      <div class="nav-right">
18427        <a class="nav-pill" href="/">Home</a>
18428        <div class="nav-dropdown">
18429          <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>
18430          <div class="nav-dropdown-menu">
18431            <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>
18432          </div>
18433        </div>
18434        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
18435        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18436        <div class="nav-dropdown">
18437          <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>
18438          <div class="nav-dropdown-menu">
18439            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
18440          </div>
18441        </div>
18442        <div class="server-status-wrap" id="server-status-wrap">
18443          <div class="nav-pill server-online-pill" id="server-status-pill">
18444            <span class="status-dot" id="status-dot"></span>
18445            <span id="server-status-label">Server</span>
18446            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18447          </div>
18448          <div class="server-status-tip">
18449            OxideSLOC is running — accessible on your network.
18450            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18451          </div>
18452        </div>
18453        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18454          <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>
18455        </button>
18456        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18457          <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>
18458          <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>
18459        </button>
18460      </div>
18461    </div>
18462  </div>
18463
18464  <div class="page">
18465    <div class="panel">
18466      <h1>Scan Files Moved</h1>
18467      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
18468      <div class="error-box">{{ message }}</div>
18469      <div class="relocate-section">
18470        <h2>Locate Scan Output</h2>
18471        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
18472        <form method="post" action="/relocate-scan">
18473          <input type="hidden" name="run_id" value="{{ run_id }}">
18474          <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
18475          <div class="relocate-row">
18476            <input type="text" id="relocate-folder" name="folder_path"
18477                   value="{{ folder_hint }}"
18478                   placeholder="Path to folder containing scan output..."
18479                   class="relocate-input" autocomplete="off" spellcheck="false">
18480            {% if !server_mode %}
18481            <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
18482            {% endif %}
18483          </div>
18484          <div style="margin-top:12px;">
18485            <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
18486          </div>
18487        </form>
18488      </div>
18489      <div class="actions">
18490        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
18491        <a class="btn-secondary" href="/view-reports">View Reports</a>
18492      </div>
18493    </div>
18494  </div>
18495  <script nonce="{{ csp_nonce }}">
18496    (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");});})();
18497    (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);}})();
18498    (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';});})();
18499  </script>
18500  <script nonce="{{ csp_nonce }}">
18501  (function(){
18502    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'}];
18503    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);});}
18504    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18505    function init(){
18506      var btn=document.getElementById('settings-btn');if(!btn)return;
18507      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18508      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>';
18509      document.body.appendChild(m);
18510      var g=document.getElementById('scheme-grid');
18511      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);});
18512      var cl=document.getElementById('settings-close');
18513      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);
18514      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');});
18515      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18516      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18517    }
18518    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18519  }());
18520  (function(){
18521    var btn=document.getElementById('browse-relocate-btn');
18522    if(!btn)return;
18523    btn.addEventListener('click',function(){
18524      btn.disabled=true;btn.textContent='...';
18525      var inp=document.getElementById('relocate-folder');
18526      var hint=inp?inp.value:'';
18527      fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
18528        .then(function(r){return r.ok?r.json():{cancelled:true};})
18529        .then(function(d){
18530          btn.disabled=false;btn.textContent='Browse…';
18531          if(d&&d.selected_path&&inp)inp.value=d.selected_path;
18532        })
18533        .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
18534    });
18535  }());
18536  </script>
18537  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
18538</body>
18539</html>
18540"##,
18541    ext = "html"
18542)]
18543struct RelocateScanTemplate {
18544    message: String,
18545    run_id: String,
18546    folder_hint: String,
18547    redirect_url: String,
18548    server_mode: bool,
18549    csp_nonce: String,
18550    version: &'static str,
18551}
18552
18553// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
18554
18555#[derive(Template)]
18556#[template(
18557    source = r##"
18558<!doctype html>
18559<html lang="en">
18560<head>
18561  <meta charset="utf-8">
18562  <meta name="viewport" content="width=device-width, initial-scale=1">
18563  <title>OxideSLOC | View Reports</title>
18564  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18565  <style nonce="{{ csp_nonce }}">
18566    :root {
18567      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18568      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18569      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18570      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18571      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
18572    }
18573    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; }
18574    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
18575    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18576    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18577    .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);}
18578    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18579    .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));}
18580    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18581    .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;}
18582    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18583    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18584    @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; } }
18585    .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;}
18586    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18587    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18588    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18589    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18590    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18591    .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;}
18592    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18593    .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);}
18594    .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;}
18595    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18596    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18597    .settings-modal-body{padding:14px 16px 16px;}
18598    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18599    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18600    .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;}
18601    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18602    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18603    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18604    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18605    .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;}
18606    .tz-select:focus{border-color:var(--oxide);}
18607    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18608    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18609    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18610    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18611    .panel-meta{font-size:13px;color:var(--muted);}
18612    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18613    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18614    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18615    .per-page-label{font-size:13px;color:var(--muted);}
18616    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;}
18617    .filter-input{min-width:180px;cursor:text;}
18618    .table-wrap{width:100%;overflow-x:auto;}
18619    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
18620    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;}
18621    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18622    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18623    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18624    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18625    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18626    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18627    tr:last-child td{border-bottom:none;}
18628    tr:hover td{background:var(--surface-2);}
18629    .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);}
18630    .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);}
18631    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18632    .metric-num{font-weight:700;color:var(--text);}
18633    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18634    .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;}
18635    .btn:hover{background:var(--line);}
18636    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18637    .btn.primary:hover{opacity:.9;}
18638    .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;}
18639    .btn-back:hover{background:var(--line);}
18640    .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;}
18641    .export-btn:hover{background:var(--line);}
18642    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
18643    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
18644    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
18645    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18646    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18647    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18648    .pagination-info{font-size:13px;color:var(--muted);}
18649    .pagination-btns{display:flex;gap:6px;}
18650    .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;}
18651    .pg-btn:hover:not(:disabled){background:var(--line);}
18652    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18653    .pg-btn:disabled{opacity:.35;cursor:default;}
18654    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18655    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18656    .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;}
18657    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18658    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18659    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18660    .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);}
18661    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18662    .stat-chip:hover .stat-chip-tip{opacity:1;}
18663    .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;}
18664    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18665    .site-footer a{color:var(--muted);}
18666    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18667    .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%;}
18668    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
18669    .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;}
18670    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18671    .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;}
18672    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18673    .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;}
18674    .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;}
18675    .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;}
18676    @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));}}
18677    .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;}
18678    .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;}
18679    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18680    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18681    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18682    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18683    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18684    .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;}
18685    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18686    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18687    .watched-chip-rm:hover{color:var(--oxide);}
18688    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18689    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18690    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18691    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18692    .rpt-btn{min-width:58px;justify-content:center;}
18693    .flex-row{display:flex;align-items:center;gap:8px;}
18694    .report-cell{overflow:visible;white-space:normal;}
18695    #history-table col:nth-child(1){width:185px;}
18696    #history-table col:nth-child(2){width:220px;}
18697    #history-table col:nth-child(3){width:100px;}
18698    #history-table col:nth-child(4){width:72px;}
18699    #history-table col:nth-child(5){width:82px;}
18700    #history-table col:nth-child(6){width:82px;}
18701    #history-table col:nth-child(7){width:65px;}
18702    #history-table col:nth-child(8){width:90px;}
18703    #history-table col:nth-child(9){width:85px;}
18704    #history-table col:nth-child(10){width:115px;}
18705    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
18706    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
18707    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
18708    .submod-details summary::-webkit-details-marker{display:none;}
18709.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
18710    .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;}
18711    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
18712    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
18713  </style>
18714</head>
18715<body>
18716  <div class="background-watermarks" aria-hidden="true">
18717    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18718    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18719    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18720    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18721    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18722    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18723  </div>
18724  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18725  <div class="top-nav">
18726    <div class="top-nav-inner">
18727      <a class="brand" href="/">
18728        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18729        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
18730      </a>
18731      <div class="nav-right">
18732        <a class="nav-pill" href="/">Home</a>
18733        <div class="nav-dropdown">
18734          <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>
18735          <div class="nav-dropdown-menu">
18736            <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>
18737          </div>
18738        </div>
18739        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18740        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18741        <div class="nav-dropdown">
18742          <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>
18743          <div class="nav-dropdown-menu">
18744            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
18745          </div>
18746        </div>
18747        <div class="server-status-wrap" id="server-status-wrap">
18748          <div class="nav-pill server-online-pill" id="server-status-pill">
18749            <span class="status-dot" id="status-dot"></span>
18750            <span id="server-status-label">Server</span>
18751            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18752          </div>
18753          <div class="server-status-tip">
18754            OxideSLOC is running — accessible on your network.
18755            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18756          </div>
18757        </div>
18758        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18759          <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>
18760        </button>
18761        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18762          <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>
18763          <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>
18764        </button>
18765      </div>
18766    </div>
18767  </div>
18768
18769  <div class="page">
18770    {% if let Some(err) = browse_error %}
18771    <div class="toast-error">
18772      <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>
18773      {{ err }}
18774    </div>
18775    {% endif %}
18776    {% if linked_count > 0 %}
18777    <div class="toast-success">
18778      <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>
18779      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
18780    </div>
18781    {% endif %}
18782    <div class="watched-bar">
18783      <div class="watched-bar-left">
18784        <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>
18785        <span class="watched-label">Watched Folders</span>
18786        <div class="watched-chips">
18787          {% if server_mode %}
18788          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18789          {% else %}
18790          {% for dir in watched_dirs %}
18791          <span class="watched-chip">
18792            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18793            <form method="POST" action="/watched-dirs/remove" style="display:contents">
18794              <input type="hidden" name="folder_path" value="{{ dir }}">
18795              <input type="hidden" name="redirect_to" value="/view-reports">
18796              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
18797            </form>
18798          </span>
18799          {% endfor %}
18800          {% if watched_dirs.is_empty() %}
18801          <span class="watched-none">No folders watched — click Choose to add one</span>
18802          {% endif %}
18803          {% endif %}
18804        </div>
18805      </div>
18806      {% if !server_mode %}
18807      <div class="watched-bar-right">
18808        <button type="button" class="btn" id="add-watched-btn">
18809          <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>
18810          Choose
18811        </button>
18812        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18813          <input type="hidden" name="redirect_to" value="/view-reports">
18814          <button type="submit" class="btn">&#8635; Refresh</button>
18815        </form>
18816      </div>
18817      {% endif %}
18818    </div>
18819    {% if total_scans > 0 %}
18820    <div class="summary-strip">
18821      <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>
18822      <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>
18823      <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>
18824      <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>
18825    </div>
18826    {% endif %}
18827
18828    <section class="panel">
18829      <div class="panel-header">
18830        <div>
18831          <h1>View Reports</h1>
18832          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
18833          {% if server_mode %}<p class="panel-meta" style="margin-top:4px;color:var(--muted);">Showing all scans from all users on this server — scan history is shared across authenticated sessions.</p>{% endif %}
18834        </div>
18835        <div class="flex-row">
18836          <button type="button" class="export-btn" id="export-csv-btn">
18837            <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>
18838            Export CSV
18839          </button>
18840          <button type="button" class="export-btn" id="export-xls-btn">
18841            <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>
18842            Export Excel
18843          </button>
18844        </div>
18845      </div>
18846
18847      {% if entries.is_empty() %}
18848      <div class="empty-state">
18849        <strong>No reports with viewable HTML yet</strong>
18850        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.
18851      </div>
18852      {% else %}
18853      <div class="filter-row">
18854        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
18855        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18856        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
18857      </div>
18858      <div class="table-wrap">
18859        <table id="history-table">
18860          <colgroup>
18861            <col><col><col><col><col><col><col><col><col><col>
18862          </colgroup>
18863          <thead>
18864            <tr id="history-thead">
18865              <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>
18866              <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>
18867              <th>Run ID<div class="col-resize-handle"></div></th>
18868              <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>
18869              <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>
18870              <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>
18871              <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>
18872              <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>
18873              <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>
18874              <th>Report<div class="col-resize-handle"></div></th>
18875            </tr>
18876          </thead>
18877          <tbody id="history-tbody">
18878            {% for entry in entries %}
18879            <tr class="history-row" data-run="{{ entry.run_id }}"
18880                data-timestamp="{{ entry.timestamp }}"
18881                data-project="{{ entry.project_label }}"
18882                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
18883                data-skipped="{{ entry.files_skipped }}"
18884                data-comments="{{ entry.comment_lines }}"
18885                data-blank="{{ entry.blank_lines }}"
18886                data-branch="{{ entry.git_branch }}"
18887                data-commit="{{ entry.git_commit }}"
18888                data-html-url="/runs/html/{{ entry.run_id }}">
18889              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18890              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18891              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
18892              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
18893              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18894              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18895              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18896              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
18897              <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>
18898              <td class="report-cell">
18899                <div class="actions-cell">
18900                  {% 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 %}
18901                  {% 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 %}
18902                </div>
18903                {% if !entry.submodule_links.is_empty() %}
18904                <details class="submod-details">
18905                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
18906                  <div class="submod-link-list">
18907                    {% for sub in entry.submodule_links %}
18908                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
18909                    {% endfor %}
18910                  </div>
18911                </details>
18912                {% endif %}
18913              </td>
18914            </tr>
18915            {% endfor %}
18916          </tbody>
18917        </table>
18918      </div>
18919      <div class="pagination">
18920        <span class="pagination-info" id="pagination-info"></span>
18921        <div class="pagination-btns" id="pagination-btns"></div>
18922        <div class="flex-row">
18923          <span class="per-page-label">Show</span>
18924          <select class="per-page" id="per-page-sel">
18925            <option value="10">10 per page</option>
18926            <option value="25" selected>25 per page</option>
18927            <option value="50">50 per page</option>
18928            <option value="100">100 per page</option>
18929          </select>
18930          <span class="per-page-label" id="page-range-label"></span>
18931        </div>
18932      </div>
18933      {% endif %}
18934    </section>
18935  </div>
18936
18937  <footer class="site-footer">
18938    local code analysis - metrics, history and reports
18939    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
18940    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18941    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18942    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18943    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
18944  </footer>
18945
18946  <script nonce="{{ csp_nonce }}">
18947    (function () {
18948      // ── Theme ──────────────────────────────────────────────────────────────
18949      var storageKey = 'oxide-sloc-theme';
18950      var body = document.body;
18951      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
18952      var toggle = document.getElementById('theme-toggle');
18953      if (toggle) toggle.addEventListener('click', function () {
18954        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
18955        body.classList.toggle('dark-theme', next === 'dark');
18956        try { localStorage.setItem(storageKey, next); } catch(e) {}
18957      });
18958
18959      // ── State ─────────────────────────────────────────────────────────────
18960      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
18961      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
18962      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
18963
18964      // Aggregate stats from first (most recent) row
18965      if (allRows.length) {
18966        var first = allRows[0];
18967        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();}
18968        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>':'');}
18969        setChipVal('agg-code', first.dataset.code);
18970        setChipVal('agg-files', first.dataset.files);
18971        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
18972        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
18973      }
18974
18975      // ── Branch filter population ──────────────────────────────────────────
18976      (function() {
18977        var branches = {};
18978        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
18979        var sel = document.getElementById('branch-filter');
18980        if (sel) Object.keys(branches).sort().forEach(function(b) {
18981          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
18982        });
18983      })();
18984
18985      // ── Filter ────────────────────────────────────────────────────────────
18986      function getFilteredRows() {
18987        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
18988        var branch = ((document.getElementById('branch-filter') || {}).value || '');
18989        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
18990          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
18991          if (branch && (r.dataset.branch || '') !== branch) return false;
18992          return true;
18993        });
18994      }
18995
18996      // ── Pagination ────────────────────────────────────────────────────────
18997      function renderPage() {
18998        var filtered = getFilteredRows();
18999        var total = filtered.length;
19000        var totalPages = Math.max(1, Math.ceil(total / perPage));
19001        currentPage = Math.min(currentPage, totalPages);
19002        var start = (currentPage - 1) * perPage;
19003        var end = Math.min(start + perPage, total);
19004        var shown = {};
19005        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19006        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
19007          r.style.display = shown[r.dataset.run] ? '' : 'none';
19008        });
19009        var rl = document.getElementById('page-range-label');
19010        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19011        var info = document.getElementById('pagination-info');
19012        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19013        var btns = document.getElementById('pagination-btns');
19014        if (!btns) return;
19015        btns.innerHTML = '';
19016        function makeBtn(lbl, pg, active, disabled) {
19017          var b = document.createElement('button');
19018          b.className = 'pg-btn' + (active ? ' active' : '');
19019          b.textContent = lbl; b.disabled = disabled;
19020          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19021          return b;
19022        }
19023        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19024        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19025        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19026        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19027      }
19028
19029      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19030      window.applyFilters = function() { currentPage = 1; renderPage(); };
19031
19032      // ── Sorting ───────────────────────────────────────────────────────────
19033      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
19034      function doSort(col, type, order) {
19035        var tbody = document.getElementById('history-tbody');
19036        if (!tbody) return;
19037        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
19038        rows.sort(function(a, b) {
19039          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19040          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19041          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19042          return va < vb ? 1 : va > vb ? -1 : 0;
19043        });
19044        rows.forEach(function(r) { tbody.appendChild(r); });
19045        currentPage = 1; renderPage();
19046      }
19047      sortHeaders.forEach(function(th) {
19048        th.addEventListener('click', function(e) {
19049          if (e.target.classList.contains('col-resize-handle')) return;
19050          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19051          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19052          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19053          th.classList.add('sort-' + sortOrder);
19054          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19055          doSort(col, type, sortOrder);
19056        });
19057      });
19058
19059      // ── Column resize ─────────────────────────────────────────────────────
19060      (function() {
19061        var table = document.getElementById('history-table');
19062        if (!table) return;
19063        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19064        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
19065        ths.forEach(function(th, i) {
19066          var handle = th.querySelector('.col-resize-handle');
19067          if (!handle || !cols[i]) return;
19068          var startX, startW;
19069          handle.addEventListener('mousedown', function(e) {
19070            e.stopPropagation(); e.preventDefault();
19071            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19072            handle.classList.add('dragging');
19073            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19074            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19075            document.addEventListener('mousemove', onMove);
19076            document.addEventListener('mouseup', onUp);
19077          });
19078        });
19079      })();
19080
19081      // ── Reset view ────────────────────────────────────────────────────────
19082      window.resetView = function() {
19083        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19084        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19085        sortCol = null; sortOrder = 'asc';
19086        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19087        var tbody = document.getElementById('history-tbody');
19088        if (tbody) {
19089          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
19090          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19091          rows.forEach(function(r) { tbody.appendChild(r); });
19092        }
19093        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19094        var table = document.getElementById('history-table');
19095        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
19096        currentPage = 1; renderPage();
19097      };
19098
19099      renderPage();
19100
19101      // ── Export helpers ────────────────────────────────────────────────────
19102      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
19103      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
19104      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);}
19105      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;');}
19106      function slocXlsx(fname,sheet,hdrs,rows){
19107        var enc=new TextEncoder();
19108        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;}
19109        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;}
19110        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
19111        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
19112        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
19113        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;}
19114        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];}
19115        var rx='<row r="1">';
19116        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
19117        rx+='</row>';
19118        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>';});
19119        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
19120        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>';
19121        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>';
19122        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>';
19123        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>',
19124          '_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>',
19125          '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>',
19126          '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>',
19127          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
19128        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'];
19129        var zparts=[],zcds=[],zoff=0,znf=0;
19130        order.forEach(function(name){
19131          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
19132          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]);
19133          var entry=new Uint8Array(lha.length+nb.length+sz);
19134          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
19135          zparts.push(entry);
19136          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));
19137          var cde=new Uint8Array(cda.length+nb.length);
19138          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
19139          zcds.push(cde);zoff+=entry.length;znf++;
19140        });
19141        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
19142        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]);
19143        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
19144        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
19145        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
19146        zout.set(new Uint8Array(ea),zpos);
19147        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
19148      }
19149
19150      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
19151      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;}
19152      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
19153      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
19154
19155      var csvBtn = document.getElementById('export-csv-btn');
19156      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
19157      var xlsBtn = document.getElementById('export-xls-btn');
19158      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
19159
19160      // ── Remaining CSP-safe event bindings ────────────────────────────────
19161      (function wireEvents() {
19162        var el;
19163        el = document.getElementById('reset-view-btn');
19164        if (el) el.addEventListener('click', window.resetView);
19165        el = document.getElementById('project-filter');
19166        if (el) el.addEventListener('input', window.applyFilters);
19167        el = document.getElementById('branch-filter');
19168        if (el) el.addEventListener('change', window.applyFilters);
19169        el = document.getElementById('per-page-sel');
19170        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
19171        el = document.getElementById('add-watched-btn');
19172        if (el) el.addEventListener('click', function() {
19173          fetch('/pick-directory?kind=reports')
19174            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19175            .then(function(data) {
19176              if (!data.cancelled && data.selected_path) {
19177                var form = document.createElement('form');
19178                form.method = 'POST';
19179                form.action = '/watched-dirs/add';
19180                var ri = document.createElement('input');
19181                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19182                var fi = document.createElement('input');
19183                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19184                form.appendChild(ri); form.appendChild(fi);
19185                document.body.appendChild(form);
19186                form.submit();
19187              }
19188            })
19189            .catch(function(e) { alert('Could not open folder picker: ' + e); });
19190        });
19191      })();
19192
19193      (function randomizeWatermarks() {
19194        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19195        if (!wms.length) return;
19196        var placed = [];
19197        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;}
19198        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];}
19199        var half=Math.floor(wms.length/2);
19200        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;});
19201      })();
19202
19203      (function spawnCodeParticles() {
19204        var container = document.getElementById('code-particles');
19205        if (!container) return;
19206        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'];
19207        for (var i = 0; i < 38; i++) {
19208          (function(idx) {
19209            var el = document.createElement('span');
19210            el.className = 'code-particle';
19211            el.textContent = snippets[idx % snippets.length];
19212            var left = Math.random() * 94 + 2;
19213            var top = Math.random() * 88 + 6;
19214            var dur = (Math.random() * 10 + 9).toFixed(1);
19215            var delay = (Math.random() * 18).toFixed(1);
19216            var rot = (Math.random() * 26 - 13).toFixed(1);
19217            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19218            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';
19219            container.appendChild(el);
19220          })(i);
19221        }
19222      })();
19223    })();
19224  </script>
19225  <script nonce="{{ csp_nonce }}">
19226  (function(){
19227    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'}];
19228    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);});}
19229    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19230    function init(){
19231      var btn=document.getElementById('settings-btn');if(!btn)return;
19232      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19233      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>';
19234      document.body.appendChild(m);
19235      var g=document.getElementById('scheme-grid');
19236      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);});
19237      var cl=document.getElementById('settings-close');
19238      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);
19239      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');});
19240      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19241      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19242    }
19243    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19244  }());
19245  </script>
19246  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
19247</body>
19248</html>
19249"##,
19250    ext = "html"
19251)]
19252struct HistoryTemplate {
19253    version: &'static str,
19254    entries: Vec<HistoryEntryRow>,
19255    total_scans: usize,
19256    linked_count: usize,
19257    browse_error: Option<String>,
19258    watched_dirs: Vec<String>,
19259    csp_nonce: String,
19260    server_mode: bool,
19261}
19262
19263// ── CompareSelectTemplate ──────────────────────────────────────────────────────
19264
19265#[derive(Template)]
19266#[template(
19267    source = r##"
19268<!doctype html>
19269<html lang="en">
19270<head>
19271  <meta charset="utf-8">
19272  <meta name="viewport" content="width=device-width, initial-scale=1">
19273  <title>OxideSLOC | Compare Scans</title>
19274  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19275  <style nonce="{{ csp_nonce }}">
19276    :root {
19277      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
19278      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19279      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19280      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19281      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
19282    }
19283    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19284    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
19285    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19286    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19287    .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);}
19288    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19289    .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));}
19290    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19291    .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;}
19292    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19293    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19294    @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; } }
19295    .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;}
19296    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19297    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19298    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19299    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19300    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19301    .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;}
19302    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19303    .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);}
19304    .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;}
19305    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19306    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19307    .settings-modal-body{padding:14px 16px 16px;}
19308    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19309    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19310    .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;}
19311    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19312    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19313    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19314    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19315    .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;}
19316    .tz-select:focus{border-color:var(--oxide);}
19317    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
19318    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
19319    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
19320    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
19321    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
19322    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
19323    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
19324    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
19325    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
19326    .per-page-label{font-size:13px;color:var(--muted);}
19327    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;}
19328    .filter-input{min-width:180px;cursor:text;}
19329    .table-wrap{width:100%;overflow-x:auto;}
19330    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
19331    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;}
19332    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
19333    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
19334    #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;}
19335    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
19336    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
19337    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
19338    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
19339    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
19340    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
19341    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
19342    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
19343    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
19344    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
19345    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
19346    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
19347    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19348    tr:last-child td{border-bottom:none;}
19349    tr.selected td{background:var(--sel-bg);}
19350    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
19351    tr:hover:not(.selected) td{background:var(--surface-2);}
19352    tr{cursor:pointer;}
19353    .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);}
19354    .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);}
19355    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
19356    .metric-num{font-weight:700;color:var(--text);}
19357    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
19358    .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;}
19359    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
19360    .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;}
19361    .btn:hover{background:var(--line);}
19362    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
19363    .btn.primary:hover{opacity:.9;}
19364    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
19365    .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;}
19366    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
19367    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
19368    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
19369    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
19370    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
19371    .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;}
19372    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19373    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
19374    .watched-chip-rm:hover{color:var(--oxide);}
19375    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
19376    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
19377    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
19378    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
19379    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
19380    .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;}
19381    .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;}
19382    .btn-back:hover{background:var(--line);}
19383    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
19384    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
19385    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
19386    .pagination-info{font-size:13px;color:var(--muted);}
19387    .pagination-btns{display:flex;gap:6px;}
19388    .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;}
19389    .pg-btn:hover:not(:disabled){background:var(--line);}
19390    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19391    .pg-btn:disabled{opacity:.35;cursor:default;}
19392    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
19393    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19394    .site-footer a{color:var(--muted);}
19395    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
19396    .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;}
19397    .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;}
19398    .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;}
19399    @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));}}
19400    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
19401    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
19402    .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;}
19403    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
19404    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
19405    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
19406    .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);}
19407    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
19408    .stat-chip:hover .stat-chip-tip{opacity:1;}
19409    .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;}
19410    .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;}
19411    .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%;}
19412    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
19413    .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;}
19414    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
19415    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
19416    .hidden{display:none!important;}
19417    .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%;}
19418    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
19419    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
19420    .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;}
19421    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
19422    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
19423    .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;}
19424    .scope-option:hover{background:var(--line);}
19425    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
19426    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
19427    .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;}
19428    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
19429    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
19430    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
19431    .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;}
19432  </style>
19433</head>
19434<body>
19435  <div class="background-watermarks" aria-hidden="true">
19436    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19437    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19438    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19439    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19440    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19441    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19442  </div>
19443  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19444  <div class="top-nav">
19445    <div class="top-nav-inner">
19446      <a class="brand" href="/">
19447        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19448        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
19449      </a>
19450      <div class="nav-right">
19451        <a class="nav-pill" href="/">Home</a>
19452        <div class="nav-dropdown">
19453          <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>
19454          <div class="nav-dropdown-menu">
19455            <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>
19456          </div>
19457        </div>
19458        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19459        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19460        <div class="nav-dropdown">
19461          <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>
19462          <div class="nav-dropdown-menu">
19463            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
19464          </div>
19465        </div>
19466        <div class="server-status-wrap" id="server-status-wrap">
19467          <div class="nav-pill server-online-pill" id="server-status-pill">
19468            <span class="status-dot" id="status-dot"></span>
19469            <span id="server-status-label">Server</span>
19470            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19471          </div>
19472          <div class="server-status-tip">
19473            OxideSLOC is running — accessible on your network.
19474            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19475          </div>
19476        </div>
19477        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19478          <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>
19479        </button>
19480        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19481          <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>
19482          <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>
19483        </button>
19484      </div>
19485    </div>
19486  </div>
19487
19488  <div class="page">
19489    <div class="watched-bar">
19490      <div class="watched-bar-left">
19491        <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>
19492        <span class="watched-label">Watched Folders</span>
19493        <div class="watched-chips">
19494          {% if server_mode %}
19495          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
19496          {% else %}
19497          {% for dir in watched_dirs %}
19498          <span class="watched-chip">
19499            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
19500            <form method="POST" action="/watched-dirs/remove" style="display:contents">
19501              <input type="hidden" name="folder_path" value="{{ dir }}">
19502              <input type="hidden" name="redirect_to" value="/compare-scans">
19503              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
19504            </form>
19505          </span>
19506          {% endfor %}
19507          {% if watched_dirs.is_empty() %}
19508          <span class="watched-none">No folders watched — click Choose to add one</span>
19509          {% endif %}
19510          {% endif %}
19511        </div>
19512      </div>
19513      {% if !server_mode %}
19514      <div class="watched-bar-right">
19515        <button type="button" class="btn" id="add-watched-btn">
19516          <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>
19517          Choose
19518        </button>
19519        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
19520          <input type="hidden" name="redirect_to" value="/compare-scans">
19521          <button type="submit" class="btn">&#8635; Refresh</button>
19522        </form>
19523      </div>
19524      {% endif %}
19525    </div>
19526    {% if total_scans > 0 %}
19527    <div class="summary-strip">
19528      <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>
19529      <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>
19530      <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>
19531      <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>
19532    </div>
19533    {% endif %}
19534    <section class="panel">
19535      <div class="panel-header">
19536        <div>
19537          <h1>Compare Scans</h1>
19538          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
19539        </div>
19540        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
19541          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
19542            <button class="btn primary" id="compare-btn" disabled>
19543              <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>
19544              Compare <span class="sel-count" id="sel-count">0/2</span>
19545            </button>
19546          </div>
19547        </div>
19548      </div>
19549
19550      {% if entries.is_empty() %}
19551      <div class="empty-state">
19552        <strong>No scans yet</strong>
19553        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.
19554      </div>
19555      {% else %}
19556      <div class="filter-row">
19557        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
19558        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
19559        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
19560      </div>
19561      <div class="scope-panel hidden" id="scope-panel">
19562        <div class="scope-panel-label">
19563          <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>
19564          Compare scope — choose what to include
19565        </div>
19566        <div class="scope-options" id="scope-options"></div>
19567      </div>
19568      {% if total_scans > 0 %}
19569      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
19570        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
19571          <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>
19572          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
19573        </div>
19574      </div>
19575      {% endif %}
19576      <div class="table-wrap">
19577        <table id="compare-table">
19578          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
19579          <thead>
19580            <tr id="compare-thead">
19581              <th><div class="col-resize-handle"></div></th>
19582              <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>
19583              <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>
19584              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
19585              <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>
19586              <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>
19587              <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>
19588              <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>
19589              <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>
19590              <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>
19591              <th>Submodules<div class="col-resize-handle"></div></th>
19592            </tr>
19593          </thead>
19594          <tbody id="compare-tbody">
19595            {% for entry in entries %}
19596            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
19597                data-timestamp="{{ entry.timestamp }}"
19598                data-project="{{ entry.project_label }}"
19599                data-files="{{ entry.files_analyzed }}"
19600                data-code="{{ entry.code_lines }}"
19601                data-comments="{{ entry.comment_lines }}"
19602                data-blank="{{ entry.blank_lines }}"
19603                data-branch="{{ entry.git_branch }}"
19604                data-commit="{{ entry.git_commit }}"
19605                data-submodules="{{ entry.submodule_names_csv }}">
19606              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
19607              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
19608              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
19609              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
19610              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
19611              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
19612              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
19613              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
19614              <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>
19615              <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>
19616              <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>
19617            </tr>
19618            {% endfor %}
19619          </tbody>
19620        </table>
19621      </div>
19622      <div class="pagination">
19623        <span class="pagination-info" id="pagination-info"></span>
19624        <div class="pagination-btns" id="pagination-btns"></div>
19625        <div class="flex-row">
19626          <span class="per-page-label">Show</span>
19627          <select class="per-page" id="per-page-sel">
19628            <option value="10">10 per page</option>
19629            <option value="25" selected>25 per page</option>
19630            <option value="50">50 per page</option>
19631            <option value="100">100 per page</option>
19632          </select>
19633          <span class="per-page-label" id="page-range-label"></span>
19634        </div>
19635      </div>
19636      {% endif %}
19637    </section>
19638  </div>
19639
19640  <footer class="site-footer">
19641    local code analysis - metrics, history and reports
19642    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19643    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19644    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19645    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19646    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19647  </footer>
19648
19649  <script nonce="{{ csp_nonce }}">
19650    (function () {
19651      // ── Theme ──────────────────────────────────────────────────────────────
19652      var storageKey = 'oxide-sloc-theme';
19653      var body = document.body;
19654      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19655      var toggle = document.getElementById('theme-toggle');
19656      if (toggle) toggle.addEventListener('click', function () {
19657        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19658        body.classList.toggle('dark-theme', next === 'dark');
19659        try { localStorage.setItem(storageKey, next); } catch(e) {}
19660      });
19661
19662      // ── State ─────────────────────────────────────────────────────────────
19663      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
19664      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
19665      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
19666
19667      // ── Stat chips ────────────────────────────────────────────────────────
19668      (function() {
19669        var projects = {}, latestTs = '', latestRow = null;
19670        allRows.forEach(function(r) {
19671          var p = r.dataset.project || ''; if (p) projects[p] = true;
19672          var ts = r.dataset.timestamp || '';
19673          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
19674        });
19675        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();}
19676        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>':'');}
19677        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
19678        if (latestRow) {
19679          setChipVal('agg-code', latestRow.dataset.code);
19680          setChipVal('agg-files', latestRow.dataset.files);
19681        }
19682      })();
19683
19684      // ── Branch filter population ──────────────────────────────────────────
19685      (function() {
19686        var branches = {};
19687        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
19688        var sel = document.getElementById('branch-filter');
19689        if (sel) Object.keys(branches).sort().forEach(function(b) {
19690          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
19691        });
19692      })();
19693
19694      // ── Filter ────────────────────────────────────────────────────────────
19695      function getFilteredRows() {
19696        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
19697        var branch = ((document.getElementById('branch-filter') || {}).value || '');
19698        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
19699          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
19700          if (branch && (r.dataset.branch || '') !== branch) return false;
19701          return true;
19702        });
19703      }
19704
19705      // ── Pagination ────────────────────────────────────────────────────────
19706      function renderPage() {
19707        var filtered = getFilteredRows();
19708        var total = filtered.length;
19709        var totalPages = Math.max(1, Math.ceil(total / perPage));
19710        currentPage = Math.min(currentPage, totalPages);
19711        var start = (currentPage - 1) * perPage;
19712        var end = Math.min(start + perPage, total);
19713        var shown = {};
19714        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19715        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
19716          r.style.display = shown[r.dataset.run] ? '' : 'none';
19717        });
19718        var rl = document.getElementById('page-range-label');
19719        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19720        var info = document.getElementById('pagination-info');
19721        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19722        var btns = document.getElementById('pagination-btns');
19723        if (!btns) return;
19724        btns.innerHTML = '';
19725        function makeBtn(lbl, pg, active, disabled) {
19726          var b = document.createElement('button');
19727          b.className = 'pg-btn' + (active ? ' active' : '');
19728          b.textContent = lbl; b.disabled = disabled;
19729          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19730          return b;
19731        }
19732        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19733        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19734        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19735        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19736      }
19737
19738      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19739      window.applyFilters = function() { currentPage = 1; renderPage(); };
19740
19741      // ── Sorting ───────────────────────────────────────────────────────────
19742      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
19743      function doSort(col, type, order) {
19744        var tbody = document.getElementById('compare-tbody');
19745        if (!tbody) return;
19746        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19747        rows.sort(function(a, b) {
19748          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19749          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19750          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19751          return va < vb ? 1 : va > vb ? -1 : 0;
19752        });
19753        rows.forEach(function(r) { tbody.appendChild(r); });
19754        currentPage = 1; renderPage();
19755      }
19756      sortHeaders.forEach(function(th) {
19757        th.addEventListener('click', function(e) {
19758          if (e.target.classList.contains('col-resize-handle')) return;
19759          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19760          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19761          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19762          th.classList.add('sort-' + sortOrder);
19763          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19764          doSort(col, type, sortOrder);
19765        });
19766      });
19767
19768      // Apply default sort (timestamp desc) on initial load
19769      (function() {
19770        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
19771        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
19772      })();
19773
19774      // ── Column resize ─────────────────────────────────────────────────────
19775      (function() {
19776        var table = document.getElementById('compare-table');
19777        if (!table) return;
19778        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19779        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
19780        ths.forEach(function(th, i) {
19781          var handle = th.querySelector('.col-resize-handle');
19782          if (!handle || !cols[i]) return;
19783          var startX, startW;
19784          handle.addEventListener('mousedown', function(e) {
19785            e.stopPropagation(); e.preventDefault();
19786            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19787            handle.classList.add('dragging');
19788            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19789            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19790            document.addEventListener('mousemove', onMove);
19791            document.addEventListener('mouseup', onUp);
19792          });
19793        });
19794      })();
19795
19796      // ── Reset view ────────────────────────────────────────────────────────
19797      window.resetView = function() {
19798        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19799        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19800        sortCol = null; sortOrder = 'asc';
19801        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19802        var tbody = document.getElementById('compare-tbody');
19803        if (tbody) {
19804          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19805          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19806          rows.forEach(function(r) { tbody.appendChild(r); });
19807        }
19808        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19809        var table = document.getElementById('compare-table');
19810        currentPage = 1; renderPage();
19811        currentPage = 1; renderPage();
19812      };
19813
19814      renderPage();
19815
19816      // ── Row selection state ───────────────────────────────────────────────
19817      var selected = [];
19818      function updateCompareBtn() {
19819        var btn = document.getElementById('compare-btn');
19820        var cnt = document.getElementById('sel-count');
19821        if (!btn) return;
19822        btn.disabled = selected.length !== 2;
19823        if (cnt) cnt.textContent = selected.length + '/2';
19824      }
19825
19826      function toggleRow(row) {
19827        var vid = row.dataset.vid || row.dataset.run;
19828        var idx = selected.indexOf(vid);
19829        if (idx >= 0) {
19830          selected.splice(idx, 1);
19831          row.classList.remove('selected');
19832          var b = document.getElementById('badge-' + vid);
19833          if (b) b.textContent = '';
19834        } else {
19835          if (selected.length >= 2) return;
19836          selected.push(vid);
19837          row.classList.add('selected');
19838        }
19839        selected.forEach(function(v, i) {
19840          var b = document.getElementById('badge-' + v);
19841          if (b) b.textContent = i + 1;
19842        });
19843        updateCompareBtn();
19844        buildScopePanel();
19845      }
19846
19847      // ── Scope panel ───────────────────────────────────────────────────────
19848      var selectedScope = 'all';
19849
19850      function buildScopePanel() {
19851        var panel = document.getElementById('scope-panel');
19852        var opts = document.getElementById('scope-options');
19853        if (!panel || !opts) return;
19854        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19855
19856        // Collect union of submodules from both selected rows.
19857        var allSubs = {};
19858        selected.forEach(function(vid) {
19859          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
19860          if (!row) return;
19861          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
19862        });
19863        var subList = Object.keys(allSubs).sort();
19864        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19865
19866        panel.classList.remove('hidden');
19867        opts.innerHTML = '';
19868
19869        function makeOption(value, label, title) {
19870          var div = document.createElement('div');
19871          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
19872          div.dataset.scopeValue = value;
19873          if (title) div.title = title;
19874          var radio = document.createElement('span');
19875          radio.className = 'scope-option-radio';
19876          var lbl = document.createElement('span');
19877          lbl.textContent = label;
19878          div.appendChild(radio);
19879          div.appendChild(lbl);
19880          div.addEventListener('click', function() {
19881            selectedScope = value;
19882            opts.querySelectorAll('.scope-option').forEach(function(o) {
19883              o.classList.toggle('selected', o.dataset.scopeValue === value);
19884            });
19885          });
19886          return div;
19887        }
19888
19889        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
19890        var sep = document.createElement('span');
19891        sep.className = 'scope-option-sep';
19892        opts.appendChild(sep);
19893        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
19894        subList.forEach(function(s) {
19895          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
19896        });
19897      }
19898
19899      function doCompare() {
19900        if (selected.length !== 2) return;
19901        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
19902        if (selectedScope === 'super') url += '&scope=super';
19903        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
19904        window.location.href = url;
19905      }
19906
19907      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
19908      var cbtn = document.getElementById('compare-btn');
19909      if (cbtn) cbtn.addEventListener('click', doCompare);
19910      var pfEl = document.getElementById('project-filter');
19911      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
19912      var bfEl = document.getElementById('branch-filter');
19913      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
19914      var rvBtn = document.getElementById('reset-view-btn');
19915      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
19916      var ppSel = document.getElementById('per-page-sel');
19917      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
19918
19919      var cmpTbody = document.getElementById('compare-tbody');
19920      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
19921        var row = e.target.closest('.compare-row');
19922        if (row) toggleRow(row);
19923      });
19924
19925      (function randomizeWatermarks() {
19926        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19927        if (!wms.length) return;
19928        var placed = [];
19929        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;}
19930        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];}
19931        var half=Math.floor(wms.length/2);
19932        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;});
19933      })();
19934
19935      (function spawnCodeParticles() {
19936        var container = document.getElementById('code-particles');
19937        if (!container) return;
19938        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'];
19939        for (var i = 0; i < 38; i++) {
19940          (function(idx) {
19941            var el = document.createElement('span');
19942            el.className = 'code-particle';
19943            el.textContent = snippets[idx % snippets.length];
19944            var left = Math.random() * 94 + 2;
19945            var top = Math.random() * 88 + 6;
19946            var dur = (Math.random() * 10 + 9).toFixed(1);
19947            var delay = (Math.random() * 18).toFixed(1);
19948            var rot = (Math.random() * 26 - 13).toFixed(1);
19949            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19950            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';
19951            container.appendChild(el);
19952          })(i);
19953        }
19954      })();
19955
19956      // ── Watched folder picker ─────────────────────────────────────────────
19957      (function() {
19958        var btn = document.getElementById('add-watched-btn');
19959        if (!btn) return;
19960        btn.addEventListener('click', function() {
19961          fetch('/pick-directory?kind=reports')
19962            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19963            .then(function(data) {
19964              if (!data.cancelled && data.selected_path) {
19965                var form = document.createElement('form');
19966                form.method = 'POST';
19967                form.action = '/watched-dirs/add';
19968                var ri = document.createElement('input');
19969                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19970                var fi = document.createElement('input');
19971                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19972                form.appendChild(ri); form.appendChild(fi);
19973                document.body.appendChild(form);
19974                form.submit();
19975              }
19976            })
19977            .catch(function(e) { alert('Could not open folder picker: ' + e); });
19978        });
19979      })();
19980
19981      // ── Submodule chip truncation ─────────────────────────────────────────
19982      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
19983        var chips = cell.querySelectorAll('.submod-chip');
19984        var MAX = 4;
19985        if (chips.length <= MAX) return;
19986        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
19987        var badge = document.createElement('span');
19988        badge.className = 'submod-overflow-badge';
19989        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
19990        badge.textContent = '+' + (chips.length - MAX) + ' more';
19991        cell.appendChild(badge);
19992        cell.style.maxHeight = 'none';
19993      });
19994    })();
19995  </script>
19996  <script nonce="{{ csp_nonce }}">
19997  (function(){
19998    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'}];
19999    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);});}
20000    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20001    function init(){
20002      var btn=document.getElementById('settings-btn');if(!btn)return;
20003      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20004      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>';
20005      document.body.appendChild(m);
20006      var g=document.getElementById('scheme-grid');
20007      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);});
20008      var cl=document.getElementById('settings-close');
20009      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);
20010      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');});
20011      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20012      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20013    }
20014    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20015  }());
20016  </script>
20017  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
20018</body>
20019</html>
20020"##,
20021    ext = "html"
20022)]
20023struct CompareSelectTemplate {
20024    version: &'static str,
20025    entries: Vec<HistoryEntryRow>,
20026    total_scans: usize,
20027    watched_dirs: Vec<String>,
20028    csp_nonce: String,
20029    server_mode: bool,
20030}
20031
20032// ── CompareTemplate ────────────────────────────────────────────────────────────
20033
20034#[derive(Template)]
20035#[template(
20036    source = r##"
20037<!doctype html>
20038<html lang="en">
20039<head>
20040  <meta charset="utf-8">
20041  <meta name="viewport" content="width=device-width, initial-scale=1">
20042  <title>OxideSLOC | Scan Delta</title>
20043  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20044  <style nonce="{{ csp_nonce }}">
20045    :root {
20046      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
20047      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
20048      --nav:#283790; --nav-2:#013e6b;
20049      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
20050      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
20051      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
20052    }
20053    body.dark-theme {
20054      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
20055      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
20056    }
20057    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
20058    .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);}
20059    .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;}
20060    .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));}
20061    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20062    .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;}
20063    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
20064    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20065    @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; } }
20066    .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;}
20067    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
20068    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20069    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20070    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20071    .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;}
20072    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20073    .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);}
20074    .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;}
20075    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20076    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20077    .settings-modal-body{padding:14px 16px 16px;}
20078    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20079    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20080    .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;}
20081    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20082    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20083    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20084    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20085    .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;}
20086    .tz-select:focus{border-color:var(--oxide);}
20087    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
20088    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
20089    .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;}
20090    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
20091    .hero-body{display:block;}
20092    .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;}
20093    .btn-back:hover{background:var(--line);}
20094    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
20095    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
20096    .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;}
20097    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
20098    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;}
20099    .muted{color:var(--muted);font-size:14px;}
20100    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
20101    .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;}
20102    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
20103    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
20104    .vpill-arrow{font-size:20px;color:var(--muted);}
20105    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
20106    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
20107    .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;}
20108    .delta-card.delta-card-wide{padding:22px 24px;}
20109    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
20110    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
20111    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
20112    .delta-card-from{font-size:15px;color:var(--muted);}
20113    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
20114    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
20115    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
20116    .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%;}
20117    .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;}
20118    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
20119    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
20120    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
20121    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
20122    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
20123    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
20124    .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;}
20125    .meta-card-commit:hover{color:var(--oxide);}
20126    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
20127    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
20128    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
20129    .meta-value{color:var(--text);font-size:13px;}
20130    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
20131    .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;}
20132    .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);}
20133    .delta-card:hover .dc-tip{display:block;}
20134    .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;}
20135    .export-btn:hover{background:var(--line);}
20136    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
20137    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
20138    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
20139    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
20140    .delta-card-change.zero{color:var(--muted);background:transparent;}
20141    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
20142    .delta-card-pct.pos{color:var(--pos);}
20143    .delta-card-pct.neg{color:var(--neg);}
20144    .delta-card-pct.zero{color:var(--muted);}
20145    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
20146    .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;}
20147    .insight-card.insight-flag{border-color:var(--oxide);}
20148    .insight-card:hover .dc-tip{display:block;}
20149    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
20150    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
20151    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
20152    .insight-label.flag{color:var(--oxide);}
20153    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
20154    .insight-val.pos{color:var(--pos);}
20155    .insight-val.neg{color:var(--neg);}
20156    .insight-val.high{color:#c0392a;}
20157    .insight-val.med{color:#926000;}
20158    .insight-val.low{color:var(--pos);}
20159    body.dark-theme .insight-val.high{color:#ff6b6b;}
20160    body.dark-theme .insight-val.med{color:#f0c060;}
20161    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
20162    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
20163    .fc-row{display:flex;align-items:center;gap:8px;}
20164    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
20165    .fc-label{color:var(--muted);}
20166    .fc-modified .fc-count{color:#926000;}
20167    .fc-added .fc-count{color:var(--pos);}
20168    .fc-removed .fc-count{color:var(--neg);}
20169    .fc-unchanged .fc-count{color:var(--muted);}
20170    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
20171    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
20172    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
20173    .chip.modified{background:#fff2d8;color:#926000;}
20174    .chip.added{background:#e8f5ed;color:#1a8f47;}
20175    .chip.removed{background:#fdeaea;color:#b33b3b;}
20176    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
20177    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
20178    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
20179    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
20180    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
20181    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
20182    .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;}
20183    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
20184    .tab-btn:hover:not(.active){background:var(--line);}
20185    .btn-reset{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
20186    .btn-reset:hover{background:var(--line);}
20187    .table-wrap{width:100%;overflow-x:auto;}
20188    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
20189    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;}
20190    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
20191    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
20192    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
20193    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
20194    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
20195    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
20196    tr:last-child td{border-bottom:none;}
20197    tr.row-added td{background:rgba(26,143,71,0.06);}
20198    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
20199    tr.row-modified td{background:rgba(146,96,0,0.05);}
20200    tr.row-unchanged td{opacity:.6;}
20201    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
20202    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
20203    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
20204    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
20205    .status-badge.modified{background:#fff2d8;color:#926000;}
20206    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
20207    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
20208    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
20209    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
20210    .delta-val{font-weight:700;}
20211    .delta-val.pos{color:var(--pos);}
20212    .delta-val.neg{color:var(--neg);}
20213    .delta-val.zero{color:var(--muted);}
20214    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
20215    .from-to strong{color:var(--text);}
20216    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20217    .site-footer a{color:var(--muted);}
20218    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
20219    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
20220    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20221    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20222    .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;}
20223    .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;}
20224    .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;}
20225    @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));}}
20226    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
20227    .path-link:hover{color:var(--oxide-2);}
20228    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
20229    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
20230    a.vpill-id:hover{color:var(--oxide);}
20231    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
20232    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
20233    .pagination-info{font-size:13px;color:var(--muted);}
20234    .pagination-btns{display:flex;gap:6px;}
20235    .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;}
20236    .pg-btn:hover:not(:disabled){background:var(--line);}
20237    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20238    .pg-btn:disabled{opacity:.35;cursor:default;}
20239    .per-page-label{font-size:13px;color:var(--muted);}
20240    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;}
20241    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20242    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
20243    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
20244    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
20245    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
20246    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
20247    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
20248    .tab-btn.tab-unchanged{color:var(--muted);}
20249    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
20250    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
20251    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
20252    .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;}
20253    .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;}
20254    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
20255    .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;}
20256    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
20257    .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;}
20258    .submod-scope-btn:hover{background:var(--line);}
20259    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20260    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
20261    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
20262    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
20263    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
20264    body.dark-theme .ic-card{background:var(--surface-2);}
20265    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
20266    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
20267    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
20268    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
20269    #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;}
20270  </style>
20271</head>
20272<body>
20273  <div class="background-watermarks" aria-hidden="true">
20274    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20275    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20276    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20277    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20278    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20279    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20280  </div>
20281  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20282  <div class="top-nav">
20283    <div class="top-nav-inner">
20284      <a class="brand" href="/">
20285        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
20286        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
20287      </a>
20288      <div class="nav-right">
20289        <a class="nav-pill" href="/">Home</a>
20290        <div class="nav-dropdown">
20291          <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>
20292          <div class="nav-dropdown-menu">
20293            <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>
20294          </div>
20295        </div>
20296        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20297        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20298        <div class="nav-dropdown">
20299          <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>
20300          <div class="nav-dropdown-menu">
20301            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
20302          </div>
20303        </div>
20304        <div class="server-status-wrap" id="server-status-wrap">
20305          <div class="nav-pill server-online-pill" id="server-status-pill">
20306            <span class="status-dot" id="status-dot"></span>
20307            <span id="server-status-label">Server</span>
20308            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20309          </div>
20310          <div class="server-status-tip">
20311            OxideSLOC is running — accessible on your network.
20312            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20313          </div>
20314        </div>
20315        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20316          <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>
20317        </button>
20318        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20319          <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>
20320          <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>
20321        </button>
20322      </div>
20323    </div>
20324  </div>
20325
20326  <div class="page">
20327    <section class="hero">
20328      <div class="hero-header">
20329        <div>
20330          <h1 class="delta-title">Scan Delta</h1>
20331          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
20332          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
20333            {% if let Some(sub) = active_submodule %}
20334            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
20335            {% else if super_scope_active %}
20336            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
20337            {% else %}
20338            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
20339            {% endif %}
20340            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
20341          </div>
20342        </div>
20343        <a class="btn-back" href="/compare-scans">
20344          <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>
20345          Compare Scans
20346        </a>
20347      </div>
20348      {% if has_any_submodule_data %}
20349      <div class="submod-scope-bar">
20350        <span class="submod-scope-label">
20351          <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>
20352          Scope:
20353        </span>
20354        <div class="submod-scope-divider"></div>
20355        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
20356           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
20357           title="All files — super-repo and all submodules combined">Full scan</a>
20358        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
20359           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
20360           title="Only files that are not part of any submodule">Super-repo only</a>
20361        {% for sub in submodule_options %}
20362        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
20363           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
20364           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
20365        {% endfor %}
20366      </div>
20367      {% endif %}
20368      <div class="hero-body">
20369      <div class="meta-strip">
20370        <div class="delta-card delta-card-meta">
20371          <div class="meta-card-header">
20372            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
20373            <div class="meta-card-project-col">
20374              <div class="meta-card-project">{{ project_name }}</div>
20375              {% if has_any_submodule_data %}
20376              {% if let Some(sub) = active_submodule %}
20377              <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>
20378              {% else if super_scope_active %}
20379              <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>
20380              {% else %}
20381              <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>
20382              {% endif %}
20383              {% endif %}
20384            </div>
20385          </div>
20386          {% if !baseline_git_commit.is_empty() %}
20387          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
20388          {% else %}
20389          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
20390          {% endif %}
20391          <div class="meta-card-rows">
20392            <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>
20393            <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>
20394            <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = baseline_git_author %}<span class="meta-value"><span class="cmp-author-val">{{ author }}</span><span class="cmp-author-handle"></span></span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
20395            <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>
20396            {% if let Some(tags) = baseline_git_tags %}
20397            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
20398            {% endif %}
20399          </div>
20400        </div>
20401        <div class="delta-card delta-card-meta">
20402          <div class="meta-card-header">
20403            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
20404            <div class="meta-card-project-col">
20405              <div class="meta-card-project">{{ project_name }}</div>
20406              {% if has_any_submodule_data %}
20407              {% if let Some(sub) = active_submodule %}
20408              <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>
20409              {% else if super_scope_active %}
20410              <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>
20411              {% else %}
20412              <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>
20413              {% endif %}
20414              {% endif %}
20415            </div>
20416          </div>
20417          {% if !current_git_commit.is_empty() %}
20418          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
20419          {% else %}
20420          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
20421          {% endif %}
20422          <div class="meta-card-rows">
20423            <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>
20424            <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>
20425            <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = current_git_author %}<span class="meta-value"><span class="cmp-author-val">{{ author }}</span><span class="cmp-author-handle"></span></span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
20426            <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>
20427            {% if let Some(tags) = current_git_tags %}
20428            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
20429            {% endif %}
20430          </div>
20431        </div>
20432      </div>
20433      <div class="delta-strip">
20434        <div class="delta-card">
20435          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
20436          <div class="delta-card-label">Code lines</div>
20437          <div class="delta-card-from">Before: {{ baseline_code }}</div>
20438          <div class="delta-card-to">{{ current_code }}</div>
20439          {% 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>
20440          {% 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>
20441          {% else %}<div class="delta-card-pct zero">±0%</div>
20442          {% endif %}
20443        </div>
20444        <div class="delta-card">
20445          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
20446          <div class="delta-card-label">Files analyzed</div>
20447          <div class="delta-card-from">Before: {{ baseline_files }}</div>
20448          <div class="delta-card-to">{{ current_files }}</div>
20449          {% 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>
20450          {% 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>
20451          {% else %}<div class="delta-card-pct zero">±0%</div>
20452          {% endif %}
20453        </div>
20454        <div class="delta-card">
20455          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
20456          <div class="delta-card-label">Comment lines</div>
20457          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
20458          <div class="delta-card-to">{{ current_comments }}</div>
20459          {% 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>
20460          {% 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>
20461          {% else %}<div class="delta-card-pct zero">±0%</div>
20462          {% endif %}
20463        </div>
20464        {{ coverage_delta_card|safe }}
20465        <div class="delta-card delta-card-wide">
20466          <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>
20467          <div class="delta-card-label">File changes</div>
20468          <div class="file-changes-grid">
20469            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
20470            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
20471            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
20472            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
20473          </div>
20474        </div>
20475      </div>
20476      <div class="insights-panel">
20477        <div class="insight-card">
20478          <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>
20479          <div class="insight-label">Lines Added</div>
20480          <div class="insight-val pos">+{{ code_lines_added }}</div>
20481          <div class="insight-sub">New or grown source lines</div>
20482        </div>
20483        <div class="insight-card">
20484          <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>
20485          <div class="insight-label">Lines Removed</div>
20486          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
20487          <div class="insight-sub">Deleted or shrunk source lines</div>
20488        </div>
20489        <div class="insight-card">
20490          <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>
20491          <div class="insight-label">Churn Rate</div>
20492          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
20493          <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>
20494        </div>
20495        {% if scope_flag %}
20496        <div class="insight-card insight-flag">
20497          <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>
20498          <div class="insight-label flag">Scope Signal</div>
20499          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
20500          <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>
20501        </div>
20502        {% endif %}
20503      </div>
20504      </div>
20505    </section>
20506
20507    <section class="panel" id="inline-charts-section">
20508      <h2>Scan Delta Charts</h2>
20509      <div class="ic-grid">
20510        <div class="ic-card">
20511          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
20512          <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>
20513          <div id="ic-c1"></div>
20514        </div>
20515        <div class="ic-card" id="ic-lang-card">
20516          <div class="ic-card-h2">Language Code Delta</div>
20517          <div id="ic-c3"></div>
20518        </div>
20519        <div class="ic-card">
20520          <div class="ic-card-h2">Delta by Metric</div>
20521          <div id="ic-c2"></div>
20522        </div>
20523        <div class="ic-card">
20524          <div class="ic-card-h2">File Change Distribution</div>
20525          <div id="ic-c4"></div>
20526        </div>
20527      </div>
20528    </section>
20529
20530    <section class="panel">
20531      <h2>File-level delta</h2>
20532      <div class="filter-tabs-row">
20533        <div class="filter-tabs">
20534          <button class="tab-btn tab-all active" data-filter="all">All</button>
20535          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
20536          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
20537          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
20538          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
20539        </div>
20540        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
20541          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
20542          <div class="export-group">
20543            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
20544            <button type="button" class="export-btn" id="delta-csv-btn">
20545              <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>
20546              CSV
20547            </button>
20548            <button type="button" class="export-btn" id="delta-xls-btn">
20549              <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>
20550              Excel
20551            </button>
20552            <button type="button" class="export-btn" id="delta-charts-btn">
20553              <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>
20554              Charts
20555            </button>
20556          </div>
20557        </div>
20558      </div>
20559
20560      <div class="table-wrap">
20561      <table id="delta-table">
20562        <colgroup>
20563          <col>
20564          <col>
20565          <col>
20566          <col>
20567          <col>
20568          <col>
20569          <col>
20570        </colgroup>
20571        <thead>
20572          <tr id="delta-thead">
20573            <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>
20574            <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>
20575            <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>
20576            <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>
20577            <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>
20578            <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>
20579            <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>
20580          </tr>
20581        </thead>
20582        <tbody id="delta-tbody">
20583          {% for row in file_rows %}
20584          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
20585              data-path="{{ row.relative_path }}"
20586              data-language="{{ row.language }}"
20587              data-baseline-code="{{ row.baseline_code }}"
20588              data-current-code="{{ row.current_code }}"
20589              data-code-delta="{{ row.code_delta_str }}"
20590              data-comment-delta="{{ row.comment_delta_str }}"
20591              data-total-delta="{{ row.total_delta_str }}"
20592              data-orig-idx="">
20593            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
20594            <td class="hide-sm">{{ row.language }}</td>
20595            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
20596            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
20597            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
20598            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
20599            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
20600          </tr>
20601          {% endfor %}
20602        </tbody>
20603      </table>
20604      </div>
20605      <div class="pagination">
20606        <span class="pagination-info" id="pg-info"></span>
20607        <div class="pagination-btns" id="pg-btns"></div>
20608        <div class="flex-row">
20609          <span class="per-page-label">Show</span>
20610          <select class="per-page" id="per-page-sel">
20611            <option value="10">10 per page</option>
20612            <option value="25" selected>25 per page</option>
20613            <option value="50">50 per page</option>
20614            <option value="100">100 per page</option>
20615          </select>
20616          <span class="per-page-label" id="pg-range-label"></span>
20617        </div>
20618      </div>
20619    </section>
20620  </div>
20621
20622  <div id="ic-tt"></div>
20623
20624  <footer class="site-footer">
20625    local code analysis - metrics, history and reports
20626    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
20627    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20628    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20629    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20630    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20631  </footer>
20632
20633  <script nonce="{{ csp_nonce }}">
20634    (function () {
20635      var storageKey = 'oxide-sloc-theme';
20636      var body = document.body;
20637      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20638      var toggle = document.getElementById('theme-toggle');
20639      if (toggle) toggle.addEventListener('click', function () {
20640        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20641        body.classList.toggle('dark-theme', next === 'dark');
20642        try { localStorage.setItem(storageKey, next); } catch(e) {}
20643      });
20644
20645      (function randomizeWatermarks() {
20646        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20647        if (!wms.length) return;
20648        var placed = [];
20649        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;}
20650        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];}
20651        var half=Math.floor(wms.length/2);
20652        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;});
20653      })();
20654
20655      (function spawnCodeParticles() {
20656        var container = document.getElementById('code-particles');
20657        if (!container) return;
20658        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'];
20659        for (var i = 0; i < 38; i++) {
20660          (function(idx) {
20661            var el = document.createElement('span');
20662            el.className = 'code-particle';
20663            el.textContent = snippets[idx % snippets.length];
20664            var left = Math.random() * 94 + 2;
20665            var top = Math.random() * 88 + 6;
20666            var dur = (Math.random() * 10 + 9).toFixed(1);
20667            var delay = (Math.random() * 18).toFixed(1);
20668            var rot = (Math.random() * 26 - 13).toFixed(1);
20669            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20670            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';
20671            container.appendChild(el);
20672          })(i);
20673        }
20674      })();
20675    })();
20676
20677    var activeStatusFilter = 'all';
20678    var deltaPerPage = 25, deltaCurrPage = 1;
20679
20680    function openFolder(path) {
20681      fetch('/open-path?path=' + encodeURIComponent(path))
20682        .then(function (r) { return r.json(); })
20683        .then(function (d) {
20684          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20685        })
20686        .catch(function () {});
20687    }
20688
20689    function getDeltaFilteredRows() {
20690      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
20691        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
20692      });
20693    }
20694
20695    function renderDeltaPage() {
20696      var filtered = getDeltaFilteredRows();
20697      var total = filtered.length;
20698      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
20699      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
20700      var start = (deltaCurrPage - 1) * deltaPerPage;
20701      var end = Math.min(start + deltaPerPage, total);
20702      var shownSet = {};
20703      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
20704      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
20705        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
20706      });
20707      var rl = document.getElementById('pg-range-label');
20708      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
20709      var info = document.getElementById('pg-info');
20710      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
20711      var btns = document.getElementById('pg-btns');
20712      if (!btns) return;
20713      btns.innerHTML = '';
20714      if (totalPages <= 1) return;
20715      function makeBtn(lbl, pg, active, disabled) {
20716        var b = document.createElement('button');
20717        b.className = 'pg-btn' + (active ? ' active' : '');
20718        b.textContent = lbl; b.disabled = disabled;
20719        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
20720        return b;
20721      }
20722      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
20723      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
20724      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
20725      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
20726    }
20727
20728    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
20729
20730    function filterRows(status, btn) {
20731      activeStatusFilter = status;
20732      deltaCurrPage = 1;
20733      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
20734        b.classList.remove('active');
20735      });
20736      if (btn) btn.classList.add('active');
20737      renderDeltaPage();
20738    }
20739
20740    // ── Sorting ──────────────────────────────────────────────────────────────
20741    var sortCol = null, sortOrder = 'asc';
20742    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
20743    (function() {
20744      var tbody = document.getElementById('delta-tbody');
20745      if (!tbody) return;
20746      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20747      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
20748    })();
20749
20750    function parseDeltaNum(str) {
20751      if (!str || str === '—') return 0;
20752      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
20753    }
20754
20755    sortHeaders.forEach(function(th) {
20756      th.addEventListener('click', function(e) {
20757        if (e.target.classList.contains('col-resize-handle')) return;
20758        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
20759        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
20760        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20761        th.classList.add('sort-' + sortOrder);
20762        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
20763        var tbody = document.getElementById('delta-tbody');
20764        if (!tbody) return;
20765        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20766        rows.sort(function(a, b) {
20767          var va, vb;
20768          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
20769          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
20770          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
20771          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
20772          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20773          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20774          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20775          else { va = ''; vb = ''; }
20776          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
20777          return va < vb ? 1 : va > vb ? -1 : 0;
20778        });
20779        rows.forEach(function(r) { tbody.appendChild(r); });
20780        deltaCurrPage = 1;
20781        renderDeltaPage();
20782        var activeBtn = document.querySelector('.tab-btn.active');
20783        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20784        if (activeBtn) activeBtn.classList.add('active');
20785      });
20786    });
20787
20788    // ── Column resize ─────────────────────────────────────────────────────────
20789    (function() {
20790      var table = document.getElementById('delta-table');
20791      if (!table) return;
20792      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
20793      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
20794      ths.forEach(function(th, i) {
20795        var handle = th.querySelector('.col-resize-handle');
20796        if (!handle || !cols[i]) return;
20797        var startX, startW;
20798        handle.addEventListener('mousedown', function(e) {
20799          e.stopPropagation(); e.preventDefault();
20800          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
20801          handle.classList.add('dragging');
20802          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
20803          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
20804          document.addEventListener('mousemove', onMove);
20805          document.addEventListener('mouseup', onUp);
20806        });
20807      });
20808    })();
20809
20810    // ── Reset ─────────────────────────────────────────────────────────────────
20811    window.resetDeltaTable = function() {
20812      sortCol = null; sortOrder = 'asc';
20813      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20814      var tbody = document.getElementById('delta-tbody');
20815      if (tbody) {
20816        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20817        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
20818        rows.forEach(function(r) { tbody.appendChild(r); });
20819      }
20820      var table = document.getElementById('delta-table');
20821      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
20822      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
20823      activeStatusFilter = 'all';
20824      deltaCurrPage = 1;
20825      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20826      var allBtn = document.querySelector('.tab-btn');
20827      if (allBtn) allBtn.classList.add('active');
20828      renderDeltaPage();
20829    };
20830
20831    renderDeltaPage();
20832
20833    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
20834    (function() {
20835      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
20836        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
20837      });
20838      var resetBtn = document.getElementById('delta-reset-btn');
20839      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
20840      var csvBtn = document.getElementById('delta-csv-btn');
20841      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
20842      var xlsBtn = document.getElementById('delta-xls-btn');
20843      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
20844      var chartsBtn = document.getElementById('delta-charts-btn');
20845      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
20846      var ppSel = document.getElementById('per-page-sel');
20847      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
20848      var pathLink = document.getElementById('project-path-link');
20849      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
20850    })();
20851
20852    // ── Export helpers ────────────────────────────────────────────────────────
20853    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
20854    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
20855    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);}
20856    function slocMakeXlsx(fname,sd,dr){
20857      var enc=new TextEncoder();
20858      // CRC-32 table
20859      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;}
20860      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;}
20861      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
20862      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
20863      // Shared string table
20864      var ss=[],si={};
20865      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
20866      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20867      // Worksheet builder — each WS() call gets its own row counter R
20868      function WS(){
20869        var R=0,buf=[];
20870        function cl(c){return String.fromCharCode(65+c);}
20871        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
20872          '<v>'+S(v)+'</v></c>';}
20873        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
20874          (st?' s="'+st+'"':'')+'>'+
20875          '<v>'+(+v)+'</v></c>';}
20876        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
20877        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20878          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
20879          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
20880          '<sheetFormatPr defaultRowHeight="15"/>'+
20881          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
20882        return{sc:sc,nc:nc,row:row,xml:xml};
20883      }
20884      // Language breakdown
20885      var lm={};
20886      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;});
20887      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
20888      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
20889      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
20890      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
20891      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20892      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20893      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):'';}
20894      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
20895      // Summary sheet
20896      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
20897      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
20898      r1(s1(0,proj,2));
20899      r1(s1(0,sd.bts+' → '+sd.cts,2));
20900      r1('');
20901      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
20902      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))));
20903      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))));
20904      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))));
20905      r1('');
20906      r1(s1(0,'FILE CHANGES',8));
20907      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
20908      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
20909      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
20910      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
20911      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
20912      if(langs.length){
20913        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
20914        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
20915        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)));});
20916      }
20917      r1('');r1(s1(0,'SCAN METADATA',8));
20918      r1(s1(1,_blabel)+s1(2,_clabel));
20919      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
20920      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
20921      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"/>');
20922      // File Delta sheet
20923      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
20924      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));
20925      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)));});
20926      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
20927      // Shared strings XML
20928      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20929        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
20930        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
20931      // XLSX file map
20932      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
20933      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>',
20934        '_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>',
20935        '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>',
20936        '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>',
20937        '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>',
20938        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
20939      // ZIP packer — STORED (no compression), compatible with all XLSX readers
20940      var zparts=[],zcds=[],zoff=0,znf=0;
20941      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
20942       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
20943      ].forEach(function(name){
20944        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
20945        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]);
20946        var entry=new Uint8Array(lha.length+nb.length+sz);
20947        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
20948        zparts.push(entry);
20949        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));
20950        var cde=new Uint8Array(cda.length+nb.length);
20951        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
20952        zcds.push(cde);zoff+=entry.length;znf++;
20953      });
20954      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
20955      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]);
20956      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
20957      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
20958      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
20959      zout.set(new Uint8Array(ea),zpos);
20960      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
20961      var xurl=URL.createObjectURL(xblob);
20962      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
20963      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
20964      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
20965    }
20966    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;');}
20967    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
20968    function getExportFilename(ext){return _exportBase+'.'+ext;}
20969
20970    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 }}'};
20971    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;}
20972    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
20973    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
20974    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20975    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20976    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):'';}
20977    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
20978    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)]];}
20979    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
20980    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;}
20981    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
20982    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
20983
20984    // ── Chart HTML report ─────────────────────────────────────────────────────
20985    function slocChartReport(fname, sd, dr) {
20986      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
20987      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20988      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20989      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();}
20990      function px(n){return Math.round(n);}
20991      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
20992      // Language map
20993      var lm={};
20994      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;});
20995      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20996
20997      // Builds onmouse* attrs for interactive tooltip on each SVG element
20998      function barTT(label,val){
20999        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
21000      }
21001
21002      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
21003      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'}];
21004      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
21005      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
21006      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
21007      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21008      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"/>';}
21009      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
21010      c1mets.forEach(function(m,i){
21011        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
21012        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
21013        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>';
21014        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))+'/>';
21015        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>';
21016        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))+'/>';
21017        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>';
21018        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>';
21019        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>';
21020      });
21021      c1+='</svg>';
21022
21023      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
21024      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'}];
21025      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
21026      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
21027      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
21028      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21029      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21030      mets.forEach(function(m,i){
21031        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
21032        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
21033        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
21034        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>';
21035        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
21036        if(bw>=52){
21037          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>';
21038        }else{
21039          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
21040          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>';
21041        }
21042      });
21043      c2+='</svg>';
21044
21045      // ── Chart 3: Language Code Delta ─────────────────────────────────────
21046      var c3='';
21047      if(langs.length){
21048        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
21049        var C3W=550,c3LW=124,c3FW=52;
21050        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
21051        var L3rH=30,C3H=langs.length*L3rH+20;
21052        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21053        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21054        langs.forEach(function(l,i){
21055          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
21056          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
21057          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
21058          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
21059          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':''))+'/>';
21060          if(bw>=48){
21061            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>';
21062          }else{
21063            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
21064            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>';
21065          }
21066          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>';
21067        });
21068        c3+='</svg>';
21069      }
21070
21071      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
21072      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;});
21073      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
21074      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
21075      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21076      var ang=-Math.PI/2;
21077      segs.forEach(function(s){
21078        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21079        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
21080        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
21081        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
21082        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
21083        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)+'%')+'/>';
21084        ang+=sw;
21085      });
21086      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>';
21087      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
21088      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>';});
21089      c4+='</svg>';
21090
21091      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
21092      var ttJs='var tt=document.getElementById("ox-tt");'+
21093        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
21094        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
21095        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
21096        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
21097        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
21098        'function oxHT(){tt.style.display="none";}';
21099
21100      // body max-width keeps charts from inflating beyond design dimensions on
21101      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
21102      // each chart's height blows up proportionally, breaking the one-page layout.
21103      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;}'+
21104        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
21105        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
21106        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
21107        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
21108        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
21109        'svg{display:block;}'+
21110        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
21111        '#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;}'+
21112        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
21113      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
21114        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
21115        '<div id="ox-tt"><\/div>'+
21116        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
21117        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
21118        '<div class="two-col">'+
21119        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
21120        '<div class="leg">'+
21121        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
21122        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
21123        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
21124        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
21125        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
21126        '<\/div>'+
21127        '<div class="two-col">'+
21128        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
21129        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
21130        '<\/div>'+
21131        '<script>'+ttJs+'<\/script>'+
21132        '<\/body><\/html>';
21133      slocDownload(html, fname, 'text/html;charset=utf-8;');
21134    }
21135    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
21136    // ── Inline delta charts ────────────────────────────────────────────────────
21137    var _icTT=document.getElementById('ic-tt');
21138    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
21139    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';};
21140    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
21141    (function(){
21142      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
21143      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21144      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();}
21145      function px(n){return Math.round(n);}
21146      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
21147      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
21148      function addTT(el){if(!el)return;el.addEventListener('mouseover',function(e){var t=e.target.closest('[data-ttl]');if(t)icTT(e,t.getAttribute('data-ttl'),t.getAttribute('data-ttv'));});el.addEventListener('mouseout',function(e){if(!e.relatedTarget||!el.contains(e.relatedTarget))icHT();});el.addEventListener('mousemove',function(e){icMT(e);});}
21149      var dr=getDeltaExportRows(),sd=_sd,lm={};
21150      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;});
21151      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
21152      // Chart 1: Baseline vs Current grouped bars
21153      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'}];
21154      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
21155      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;
21156      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21157      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"/>';}
21158      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
21159      c1mets.forEach(function(m,i){
21160        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
21161        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
21162        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>';
21163        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"/>';
21164        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>';
21165        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"/>';
21166        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>';
21167        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>';
21168        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>';
21169      });
21170      c1+='</svg>';
21171      // Chart 2: Delta by Metric
21172      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'}];
21173      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
21174      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;
21175      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21176      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21177      mets.forEach(function(m,i){
21178        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);
21179        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>';
21180        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
21181        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>';}
21182        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>';}
21183      });
21184      c2+='</svg>';
21185      // Chart 3: Language Code Delta
21186      var c3='';
21187      if(langs.length){
21188        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
21189        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;
21190        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21191        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21192        langs.forEach(function(l,i){
21193          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);
21194          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
21195          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"/>';
21196          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>';}
21197          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>';}
21198          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>';
21199        });
21200        c3+='</svg>';
21201      }
21202      // Chart 4: File Change Donut
21203      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;});
21204      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
21205      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;
21206      if(segs.length===1){
21207        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
21208        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
21209        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
21210      } else {
21211        segs.forEach(function(s){
21212          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21213          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);
21214          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);
21215          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"/>';
21216          ang+=sw;
21217        });
21218      }
21219      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>';
21220      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
21221      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>';});
21222      c4+='</svg>';
21223      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
21224      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
21225      var e3=document.getElementById('ic-c3');if(e3){e3.innerHTML=langs.length?c3:'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';addTT(e3);}
21226      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
21227      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
21228      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='  /'+el.textContent.replace(/\s+/g,'');});
21229    })();
21230  </script>
21231  <script nonce="{{ csp_nonce }}">
21232  (function(){
21233    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'}];
21234    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);});}
21235    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21236    function init(){
21237      var btn=document.getElementById('settings-btn');if(!btn)return;
21238      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21239      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>';
21240      document.body.appendChild(m);
21241      var g=document.getElementById('scheme-grid');
21242      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);});
21243      var cl=document.getElementById('settings-close');
21244      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);
21245      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');});
21246      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21247      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21248    }
21249    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21250  }());
21251  </script>
21252  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
21253</body>
21254</html>
21255"##,
21256    ext = "html"
21257)]
21258// Template structs need many bool fields to pass Askama rendering flags.
21259#[allow(clippy::struct_excessive_bools)]
21260struct CompareTemplate {
21261    version: &'static str,
21262    project_label: String,
21263    baseline_git_commit: String,
21264    current_git_commit: String,
21265    baseline_run_id: String,
21266    current_run_id: String,
21267    baseline_run_id_short: String,
21268    current_run_id_short: String,
21269    baseline_timestamp: String,
21270    baseline_timestamp_utc_ms: i64,
21271    current_timestamp: String,
21272    current_timestamp_utc_ms: i64,
21273    project_path: String,
21274    baseline_code: u64,
21275    current_code: u64,
21276    code_lines_delta_str: String,
21277    code_lines_delta_class: String,
21278    baseline_files: u64,
21279    current_files: u64,
21280    files_analyzed_delta_str: String,
21281    files_analyzed_delta_class: String,
21282    baseline_comments: u64,
21283    current_comments: u64,
21284    comment_lines_delta_str: String,
21285    comment_lines_delta_class: String,
21286    code_lines_pct_str: String,
21287    files_analyzed_pct_str: String,
21288    comment_lines_pct_str: String,
21289    code_lines_added: i64,
21290    code_lines_removed: i64,
21291    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
21292    new_scope: bool,
21293    churn_rate_str: String,
21294    churn_rate_class: String,
21295    scope_flag: bool,
21296    files_added: usize,
21297    files_removed: usize,
21298    files_modified: usize,
21299    files_unchanged: usize,
21300    file_rows: Vec<CompareFileDeltaRow>,
21301    baseline_git_author: Option<String>,
21302    current_git_author: Option<String>,
21303    baseline_git_branch: String,
21304    current_git_branch: String,
21305    baseline_git_tags: Option<String>,
21306    current_git_tags: Option<String>,
21307    baseline_git_commit_date: Option<String>,
21308    current_git_commit_date: Option<String>,
21309    project_name: String,
21310    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
21311    submodule_options: Vec<String>,
21312    /// True when either run has submodule data — controls whether the scope bar is shown.
21313    has_any_submodule_data: bool,
21314    /// The submodule currently being compared, if the `sub` query param was provided.
21315    active_submodule: Option<String>,
21316    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
21317    super_scope_active: bool,
21318    csp_nonce: String,
21319    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
21320    coverage_delta_card: String,
21321}
21322
21323// ── LoginTemplate ──────────────────────────────────────────────────────────────
21324
21325#[derive(Template)]
21326#[template(
21327    source = r##"
21328<!doctype html>
21329<html lang="en">
21330<head>
21331  <meta charset="utf-8">
21332  <meta name="viewport" content="width=device-width, initial-scale=1">
21333  <title>OxideSLOC | Sign In</title>
21334  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21335  <style nonce="{{ csp_nonce }}">
21336    :root {
21337      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
21338      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
21339      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
21340      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
21341    }
21342    *{box-sizing:border-box;}
21343    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);}
21344    .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);}
21345    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
21346    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
21347    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
21348    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21349    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21350    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21351    .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;}
21352    @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));}}
21353    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
21354    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
21355    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21356    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
21357    .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;}
21358    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
21359    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;}
21360    input[type=password]:focus{border-color:var(--oxide);}
21361    .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;}
21362    .btn:hover{opacity:.88;}
21363    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
21364    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
21365  </style>
21366</head>
21367<body>
21368  <div class="background-watermarks" aria-hidden="true">
21369    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21370    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21371    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21372    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21373    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21374    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21375    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21376  </div>
21377  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21378<nav class="top-nav">
21379  <a class="brand" href="/">
21380    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21381    <span class="brand-title">OxideSLOC</span>
21382  </a>
21383</nav>
21384<main class="page">
21385  <div class="card">
21386    <h1>Sign In</h1>
21387    <p class="subtitle">Enter the API key printed when the server started.</p>
21388    {% if has_error %}
21389    <div class="error">Incorrect API key — please try again.</div>
21390    {% endif %}
21391    <form method="POST" action="/auth/login">
21392      <input type="hidden" name="next" value="{{ next_url|e }}">
21393      <label for="key">API Key</label>
21394      <input id="key" type="password" name="key" autocomplete="current-password"
21395             placeholder="Paste your API key here" autofocus>
21396      <button type="submit" class="btn">Sign In</button>
21397    </form>
21398    <p class="hint">
21399      The API key was printed in the terminal when the server started.<br>
21400      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
21401      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
21402    </p>
21403  </div>
21404</main>
21405<script nonce="{{ csp_nonce }}">
21406(function() {
21407  (function randomizeWatermarks() {
21408    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21409    if (!wms.length) return;
21410    var placed = [];
21411    function tooClose(top, left) {
21412      for (var i = 0; i < placed.length; i++) {
21413        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21414        if (dt < 16 && dl < 12) return true;
21415      }
21416      return false;
21417    }
21418    function pick(leftBand) {
21419      for (var attempt = 0; attempt < 50; attempt++) {
21420        var top = Math.random() * 88 + 2;
21421        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21422        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21423      }
21424      var top = Math.random() * 88 + 2;
21425      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21426      placed.push([top, left]); return [top, left];
21427    }
21428    var half = Math.floor(wms.length / 2);
21429    wms.forEach(function (img, i) {
21430      var pos = pick(i < half);
21431      var size = Math.floor(Math.random() * 100 + 120);
21432      var rot = (Math.random() * 360).toFixed(1);
21433      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21434      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;
21435    });
21436  })();
21437  (function spawnCodeParticles() {
21438    var container = document.getElementById('code-particles');
21439    if (!container) return;
21440    var snippets = [
21441      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21442      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21443      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21444      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21445      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21446    ];
21447    var count = 38;
21448    for (var i = 0; i < count; i++) {
21449      (function(idx) {
21450        var el = document.createElement('span');
21451        el.className = 'code-particle';
21452        el.textContent = snippets[idx % snippets.length];
21453        var left = Math.random() * 94 + 2;
21454        var top = Math.random() * 88 + 6;
21455        var dur = (Math.random() * 10 + 9).toFixed(1);
21456        var delay = (Math.random() * 18).toFixed(1);
21457        var rot = (Math.random() * 26 - 13).toFixed(1);
21458        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21459        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
21460        container.appendChild(el);
21461      })(i);
21462    }
21463  })();
21464})();
21465</script>
21466</body>
21467</html>
21468"##,
21469    ext = "html"
21470)]
21471pub(crate) struct LoginTemplate {
21472    pub(crate) csp_nonce: String,
21473    pub(crate) has_error: bool,
21474    pub(crate) next_url: String,
21475    pub(crate) lockout_threshold: u32,
21476}
21477
21478// ── REST API reference page ────────────────────────────────────────────────────
21479
21480#[derive(Template)]
21481#[template(
21482    source = r##"
21483<!doctype html>
21484<html lang="en">
21485<head>
21486  <meta charset="utf-8">
21487  <meta name="viewport" content="width=device-width, initial-scale=1">
21488  <title>OxideSLOC — REST API Reference</title>
21489  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21490  <style nonce="{{ csp_nonce }}">
21491    :root {
21492      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21493      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21494      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21495      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21496      --success:#16a34a;
21497    }
21498    body.dark-theme {
21499      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21500      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21501    }
21502    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
21503    .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);}
21504    .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;}
21505    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
21506    .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));}
21507    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21508    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21509    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
21510    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
21511    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21512    @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; } }
21513    .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;}
21514    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
21515    .nav-pill.active{background:rgba(255,255,255,0.22);}
21516    .nav-dropdown{position:relative;display:inline-flex;}
21517    .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;}
21518    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
21519    .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;}
21520    .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;}
21521    .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);}
21522    .nav-dropdown-menu a:last-child{border-bottom:none;}
21523    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
21524    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
21525    .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;}
21526    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21527    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21528    .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;}
21529    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21530    .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);}
21531    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
21532    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
21533    .settings-modal-body{padding:14px 16px 16px;}
21534    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21535    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21536    .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;}
21537    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21538    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21539    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21540    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21541    .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;}
21542    .tz-select:focus{border-color:var(--oxide);}
21543    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21544    .page-header{margin-bottom:28px;}
21545    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
21546    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
21547    .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;}
21548    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
21549    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
21550    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
21551    .callout strong{font-weight:800;}
21552    .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;}
21553    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
21554    .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;}
21555    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
21556    .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;}
21557    body.dark-theme .base-url-value{color:var(--accent);}
21558    .section{margin-bottom:36px;}
21559    .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);}
21560    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
21561    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
21562    .ep-header:hover{background:var(--surface-2);}
21563    .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;}
21564    .method.get{background:#dcfce7;color:#166534;}
21565    .method.post{background:#dbeafe;color:#1e40af;}
21566    .method.delete{background:#fee2e2;color:#991b1b;}
21567    body.dark-theme .method.get{background:#14532d;color:#86efac;}
21568    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
21569    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
21570    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
21571    .ep-path .param{color:var(--oxide-2);}
21572    body.dark-theme .ep-path .param{color:var(--oxide);}
21573    .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;}
21574    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
21575    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
21576    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
21577    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
21578    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
21579    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
21580    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
21581    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
21582    .ep-card.open .chevron{transform:rotate(180deg);}
21583    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
21584    .ep-card.open .ep-body{display:block;}
21585    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
21586    .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;}
21587    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
21588    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
21589    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21590    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
21591    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);}
21592    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
21593    table.params tr:last-child td{border-bottom:none;}
21594    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
21595    .pt-type{color:var(--muted-2);font-size:12px;}
21596    .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;}
21597    .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;}
21598    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
21599    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
21600    details.schema{margin-bottom:14px;}
21601    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;}
21602    details.schema summary:hover{color:var(--text);}
21603    .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;}
21604    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21605    .curl-wrap{position:relative;}
21606    .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;}
21607    .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;}
21608    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
21609    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
21610    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
21611    .webhook-note a{color:var(--accent-2);text-decoration:none;}
21612    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21613    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21614    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21615    .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;}
21616    @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));}}
21617    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21618    .site-footer a{color:var(--muted);}
21619  </style>
21620</head>
21621<body>
21622  <div class="background-watermarks" aria-hidden="true">
21623    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21624    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21625    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21626    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21627    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21628    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21629    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21630  </div>
21631  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21632  <div class="top-nav">
21633    <div class="top-nav-inner">
21634      <a class="brand" href="/">
21635        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21636        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
21637      </a>
21638      <div class="nav-right">
21639        <a class="nav-pill" href="/">Home</a>
21640        <div class="nav-dropdown">
21641          <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>
21642          <div class="nav-dropdown-menu">
21643            <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>
21644          </div>
21645        </div>
21646        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21647        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21648        <div class="nav-dropdown">
21649          <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>
21650          <div class="nav-dropdown-menu">
21651            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
21652          </div>
21653        </div>
21654        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21655          <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>
21656        </button>
21657        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21658          <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>
21659          <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>
21660        </button>
21661      </div>
21662    </div>
21663  </div>
21664
21665  <div class="page">
21666    <div class="page-header">
21667      <h1 class="page-title">REST API Reference</h1>
21668      <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>
21669    </div>
21670
21671    {% if has_api_key %}
21672    <div class="callout key-set">
21673      <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>
21674      <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>
21675    </div>
21676    {% else %}
21677    <div class="callout no-key">
21678      <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>
21679      <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>
21680    </div>
21681    {% endif %}
21682
21683    <div class="base-url-bar">
21684      <span class="base-url-label">Base URL</span>
21685      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
21686    </div>
21687
21688    <!-- Health -->
21689    <div class="section">
21690      <h2 class="section-title">Health &amp; Status</h2>
21691      <div class="ep-card">
21692        <div class="ep-header">
21693          <span class="method get">GET</span>
21694          <span class="ep-path">/healthz</span>
21695          <span class="auth-badge public">Public</span>
21696          <span class="ep-desc">Server liveness check</span>
21697          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21698        </div>
21699        <div class="ep-body">
21700          <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>
21701          <p class="params-heading">Response</p>
21702          <div class="schema-block">200 OK
21703Content-Type: text/plain
21704
21705ok</div>
21706          <p class="curl-heading">Example</p>
21707          <div class="curl-wrap">
21708            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
21709            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
21710          </div>
21711        </div>
21712      </div>
21713    </div>
21714
21715    <!-- Badges -->
21716    <div class="section">
21717      <h2 class="section-title">Badges</h2>
21718      <div class="ep-card">
21719        <div class="ep-header">
21720          <span class="method get">GET</span>
21721          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
21722          <span class="auth-badge public">Public</span>
21723          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
21724          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21725        </div>
21726        <div class="ep-body">
21727          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
21728          <p class="params-heading">Path Parameters</p>
21729          <table class="params">
21730            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21731            <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>
21732          </table>
21733          <p class="curl-heading">Example</p>
21734          <div class="curl-wrap">
21735            <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>
21736            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
21737          </div>
21738        </div>
21739      </div>
21740    </div>
21741
21742    <!-- Metrics -->
21743    <div class="section">
21744      <h2 class="section-title">Metrics</h2>
21745
21746      <div class="ep-card">
21747        <div class="ep-header">
21748          <span class="method get">GET</span>
21749          <span class="ep-path">/api/metrics/latest</span>
21750          <span class="auth-badge protected">Protected</span>
21751          <span class="ep-desc">Latest scan metrics (JSON)</span>
21752          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21753        </div>
21754        <div class="ep-body">
21755          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
21756          <details class="schema"><summary>Response schema</summary>
21757<div class="schema-block">{
21758  "run_id":    string,        // UUID
21759  "timestamp": string,        // ISO-8601 UTC
21760  "project":   string,        // scanned root path
21761  "summary": {
21762    "files_analyzed":       number,
21763    "files_skipped":        number,
21764    "code_lines":           number,
21765    "comment_lines":        number,
21766    "blank_lines":          number,
21767    "total_physical_lines": number,
21768    "functions":            number,
21769    "classes":              number,
21770    "variables":            number,
21771    "imports":              number
21772  },
21773  "languages": [
21774    { "name": string, "files": number, "code_lines": number,
21775      "comment_lines": number, "blank_lines": number,
21776      "functions": number, "classes": number,
21777      "variables": number, "imports": number }
21778  ]
21779}</div></details>
21780          <p class="curl-heading">Example</p>
21781          <div class="curl-wrap">
21782            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21783  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
21784            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
21785          </div>
21786        </div>
21787      </div>
21788
21789      <div class="ep-card">
21790        <div class="ep-header">
21791          <span class="method get">GET</span>
21792          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
21793          <span class="auth-badge protected">Protected</span>
21794          <span class="ep-desc">Metrics for a specific run</span>
21795          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21796        </div>
21797        <div class="ep-body">
21798          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
21799          <p class="params-heading">Path Parameters</p>
21800          <table class="params">
21801            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21802            <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>
21803          </table>
21804          <p class="curl-heading">Example</p>
21805          <div class="curl-wrap">
21806            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21807  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
21808            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
21809          </div>
21810        </div>
21811      </div>
21812
21813      <div class="ep-card">
21814        <div class="ep-header">
21815          <span class="method get">GET</span>
21816          <span class="ep-path">/api/metrics/history</span>
21817          <span class="auth-badge protected">Protected</span>
21818          <span class="ep-desc">Paginated scan history</span>
21819          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21820        </div>
21821        <div class="ep-body">
21822          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
21823          <p class="params-heading">Query Parameters</p>
21824          <table class="params">
21825            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21826            <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>
21827            <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>
21828          </table>
21829          <details class="schema"><summary>Response schema</summary>
21830<div class="schema-block">[{
21831  "run_id":         string,
21832  "timestamp":      string,   // ISO-8601 UTC
21833  "commit":         string | null,
21834  "branch":         string | null,
21835  "tags":           string[],
21836  "code_lines":     number,
21837  "comment_lines":  number,
21838  "blank_lines":    number,
21839  "physical_lines": number,
21840  "files_analyzed": number,
21841  "project_label":  string,
21842  "html_url":       string | null
21843}]</div></details>
21844          <p class="curl-heading">Example</p>
21845          <div class="curl-wrap">
21846            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21847  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
21848            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
21849          </div>
21850        </div>
21851      </div>
21852
21853      <div class="ep-card">
21854        <div class="ep-header">
21855          <span class="method get">GET</span>
21856          <span class="ep-path">/api/project-history</span>
21857          <span class="auth-badge protected">Protected</span>
21858          <span class="ep-desc">Project-level scan summary</span>
21859          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21860        </div>
21861        <div class="ep-body">
21862          <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>
21863          <p class="params-heading">Query Parameters</p>
21864          <table class="params">
21865            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21866            <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>
21867          </table>
21868          <details class="schema"><summary>Response schema</summary>
21869<div class="schema-block">{
21870  "scan_count":           number,
21871  "last_scan_id":         string | null,
21872  "last_scan_timestamp":  string | null,  // ISO-8601
21873  "last_scan_code_lines": number | null,
21874  "last_git_branch":      string | null,
21875  "last_git_commit":      string | null
21876}</div></details>
21877          <p class="curl-heading">Example</p>
21878          <div class="curl-wrap">
21879            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21880  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
21881            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
21882          </div>
21883        </div>
21884      </div>
21885
21886      <div class="ep-card">
21887        <div class="ep-header">
21888          <span class="method get">GET</span>
21889          <span class="ep-path">/api/metrics/submodules</span>
21890          <span class="auth-badge protected">Protected</span>
21891          <span class="ep-desc">List known git submodules across scans</span>
21892          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21893        </div>
21894        <div class="ep-body">
21895          <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>
21896          <p class="params-heading">Query Parameters</p>
21897          <table class="params">
21898            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21899            <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>
21900          </table>
21901          <details class="schema"><summary>Response schema</summary>
21902<div class="schema-block">[{
21903  "name":          string,  // submodule name
21904  "relative_path": string   // path relative to the project root
21905}]</div></details>
21906          <p class="curl-heading">Example</p>
21907          <div class="curl-wrap">
21908            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21909  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
21910            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
21911          </div>
21912        </div>
21913      </div>
21914    </div>
21915
21916    <!-- Async Run Status -->
21917    <div class="section">
21918      <h2 class="section-title">Async Run Status</h2>
21919
21920      <div class="ep-card">
21921        <div class="ep-header">
21922          <span class="method get">GET</span>
21923          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
21924          <span class="auth-badge protected">Protected</span>
21925          <span class="ep-desc">Poll scan completion</span>
21926          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21927        </div>
21928        <div class="ep-body">
21929          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
21930          <details class="schema"><summary>Response schema</summary>
21931<div class="schema-block">// Running
21932{ "state": "running",  "elapsed_secs": number }
21933
21934// Complete
21935{ "state": "complete", "run_id": string }
21936
21937// Failed
21938{ "state": "failed",   "message": string }</div></details>
21939          <p class="curl-heading">Example</p>
21940          <div class="curl-wrap">
21941            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21942  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
21943            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
21944          </div>
21945        </div>
21946      </div>
21947
21948      <div class="ep-card">
21949        <div class="ep-header">
21950          <span class="method get">GET</span>
21951          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
21952          <span class="auth-badge protected">Protected</span>
21953          <span class="ep-desc">Poll PDF generation readiness</span>
21954          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21955        </div>
21956        <div class="ep-body">
21957          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
21958          <details class="schema"><summary>Response schema</summary>
21959<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
21960          <p class="curl-heading">Example</p>
21961          <div class="curl-wrap">
21962            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21963  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
21964            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
21965          </div>
21966        </div>
21967      </div>
21968
21969      <div class="ep-card">
21970        <div class="ep-header">
21971          <span class="method post">POST</span>
21972          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
21973          <span class="auth-badge protected">Protected</span>
21974          <span class="ep-desc">Cancel a running scan</span>
21975          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21976        </div>
21977        <div class="ep-body">
21978          <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>
21979          <p class="curl-heading">Example</p>
21980          <div class="curl-wrap">
21981            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
21982  -H "Authorization: Bearer $SLOC_API_KEY" \
21983  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
21984            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
21985          </div>
21986        </div>
21987      </div>
21988    </div>
21989
21990    <!-- Scan Profiles -->
21991    <div class="section">
21992      <h2 class="section-title">Scan Profiles</h2>
21993
21994      <div class="ep-card">
21995        <div class="ep-header">
21996          <span class="method get">GET</span>
21997          <span class="ep-path">/api/scan-profiles</span>
21998          <span class="auth-badge protected">Protected</span>
21999          <span class="ep-desc">List saved scan profiles</span>
22000          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22001        </div>
22002        <div class="ep-body">
22003          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
22004          <details class="schema"><summary>Response schema</summary>
22005<div class="schema-block">{
22006  "profiles": [{
22007    "id":         string,   // UUID
22008    "name":       string,
22009    "created_at": string,   // ISO-8601
22010    "params":     object
22011  }]
22012}</div></details>
22013          <p class="curl-heading">Example</p>
22014          <div class="curl-wrap">
22015            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22016  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
22017            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
22018          </div>
22019        </div>
22020      </div>
22021
22022      <div class="ep-card">
22023        <div class="ep-header">
22024          <span class="method post">POST</span>
22025          <span class="ep-path">/api/scan-profiles</span>
22026          <span class="auth-badge protected">Protected</span>
22027          <span class="ep-desc">Save a scan profile</span>
22028          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22029        </div>
22030        <div class="ep-body">
22031          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
22032          <p class="params-heading">Request Body (application/json)</p>
22033          <table class="params">
22034            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22035            <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>
22036            <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>
22037          </table>
22038          <details class="schema"><summary>Response schema</summary>
22039<div class="schema-block">{ "ok": true }</div></details>
22040          <p class="curl-heading">Example</p>
22041          <div class="curl-wrap">
22042            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
22043  -H "Authorization: Bearer $SLOC_API_KEY" \
22044  -H "Content-Type: application/json" \
22045  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
22046  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
22047            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
22048          </div>
22049        </div>
22050      </div>
22051
22052      <div class="ep-card">
22053        <div class="ep-header">
22054          <span class="method delete">DELETE</span>
22055          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
22056          <span class="auth-badge protected">Protected</span>
22057          <span class="ep-desc">Delete a scan profile</span>
22058          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22059        </div>
22060        <div class="ep-body">
22061          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
22062          <p class="params-heading">Path Parameters</p>
22063          <table class="params">
22064            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22065            <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>
22066          </table>
22067          <details class="schema"><summary>Response schema</summary>
22068<div class="schema-block">{ "ok": true }</div></details>
22069          <p class="curl-heading">Example</p>
22070          <div class="curl-wrap">
22071            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
22072  -H "Authorization: Bearer $SLOC_API_KEY" \
22073  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
22074            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
22075          </div>
22076        </div>
22077      </div>
22078    </div>
22079
22080    <!-- Scheduled Scans -->
22081    <div class="section">
22082      <h2 class="section-title">Scheduled Scans</h2>
22083
22084      <div class="ep-card">
22085        <div class="ep-header">
22086          <span class="method get">GET</span>
22087          <span class="ep-path">/api/schedules</span>
22088          <span class="auth-badge protected">Protected</span>
22089          <span class="ep-desc">List configured schedules</span>
22090          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22091        </div>
22092        <div class="ep-body">
22093          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
22094          <p class="curl-heading">Example</p>
22095          <div class="curl-wrap">
22096            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22097  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22098            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
22099          </div>
22100        </div>
22101      </div>
22102
22103      <div class="ep-card">
22104        <div class="ep-header">
22105          <span class="method post">POST</span>
22106          <span class="ep-path">/api/schedules</span>
22107          <span class="auth-badge protected">Protected</span>
22108          <span class="ep-desc">Create a schedule</span>
22109          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22110        </div>
22111        <div class="ep-body">
22112          <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>
22113          <p class="curl-heading">Example</p>
22114          <div class="curl-wrap">
22115            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
22116  -H "Authorization: Bearer $SLOC_API_KEY" \
22117  -H "Content-Type: application/json" \
22118  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
22119  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22120            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
22121          </div>
22122        </div>
22123      </div>
22124
22125      <div class="ep-card">
22126        <div class="ep-header">
22127          <span class="method delete">DELETE</span>
22128          <span class="ep-path">/api/schedules</span>
22129          <span class="auth-badge protected">Protected</span>
22130          <span class="ep-desc">Delete a schedule</span>
22131          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22132        </div>
22133        <div class="ep-body">
22134          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
22135          <p class="curl-heading">Example</p>
22136          <div class="curl-wrap">
22137            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
22138  -H "Authorization: Bearer $SLOC_API_KEY" \
22139  -H "Content-Type: application/json" \
22140  -d '{"id":"&lt;schedule_id&gt;"}' \
22141  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22142            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
22143          </div>
22144        </div>
22145      </div>
22146    </div>
22147
22148    <!-- Git Browser -->
22149    <div class="section">
22150      <h2 class="section-title">Git Browser</h2>
22151
22152      <div class="ep-card">
22153        <div class="ep-header">
22154          <span class="method get">GET</span>
22155          <span class="ep-path">/api/git/refs</span>
22156          <span class="auth-badge protected">Protected</span>
22157          <span class="ep-desc">List git refs for a repository</span>
22158          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22159        </div>
22160        <div class="ep-body">
22161          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
22162          <p class="params-heading">Query Parameters</p>
22163          <table class="params">
22164            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22165            <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>
22166          </table>
22167          <p class="curl-heading">Example</p>
22168          <div class="curl-wrap">
22169            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22170  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
22171            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
22172          </div>
22173        </div>
22174      </div>
22175
22176      <div class="ep-card">
22177        <div class="ep-header">
22178          <span class="method get">GET</span>
22179          <span class="ep-path">/api/git/scan-ref</span>
22180          <span class="auth-badge protected">Protected</span>
22181          <span class="ep-desc">SLOC-scan a specific git ref</span>
22182          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22183        </div>
22184        <div class="ep-body">
22185          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
22186          <p class="params-heading">Query Parameters</p>
22187          <table class="params">
22188            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22189            <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>
22190            <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>
22191          </table>
22192          <p class="curl-heading">Example</p>
22193          <div class="curl-wrap">
22194            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22195  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
22196            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
22197          </div>
22198        </div>
22199      </div>
22200
22201      <div class="ep-card">
22202        <div class="ep-header">
22203          <span class="method get">GET</span>
22204          <span class="ep-path">/api/git/compare-refs</span>
22205          <span class="auth-badge protected">Protected</span>
22206          <span class="ep-desc">Compare SLOC across two git refs</span>
22207          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22208        </div>
22209        <div class="ep-body">
22210          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
22211          <p class="params-heading">Query Parameters</p>
22212          <table class="params">
22213            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22214            <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>
22215            <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>
22216            <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>
22217          </table>
22218          <p class="curl-heading">Example</p>
22219          <div class="curl-wrap">
22220            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22221  "<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>
22222            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
22223          </div>
22224        </div>
22225      </div>
22226    </div>
22227
22228    <!-- Webhooks -->
22229    <div class="section">
22230      <h2 class="section-title">Webhooks</h2>
22231      <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>
22232
22233      <div class="ep-card">
22234        <div class="ep-header">
22235          <span class="method post">POST</span>
22236          <span class="ep-path">/webhooks/github</span>
22237          <span class="auth-badge hmac">HMAC</span>
22238          <span class="ep-desc">GitHub push event receiver</span>
22239          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22240        </div>
22241        <div class="ep-body">
22242          <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>
22243          <p class="params-heading">Required Headers</p>
22244          <table class="params">
22245            <tr><th>Header</th><th>Value</th></tr>
22246            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
22247            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
22248            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22249          </table>
22250        </div>
22251      </div>
22252
22253      <div class="ep-card">
22254        <div class="ep-header">
22255          <span class="method post">POST</span>
22256          <span class="ep-path">/webhooks/gitlab</span>
22257          <span class="auth-badge hmac">HMAC</span>
22258          <span class="ep-desc">GitLab push event receiver</span>
22259          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22260        </div>
22261        <div class="ep-body">
22262          <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>
22263          <p class="params-heading">Required Headers</p>
22264          <table class="params">
22265            <tr><th>Header</th><th>Value</th></tr>
22266            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
22267            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
22268            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22269          </table>
22270        </div>
22271      </div>
22272
22273      <div class="ep-card">
22274        <div class="ep-header">
22275          <span class="method post">POST</span>
22276          <span class="ep-path">/webhooks/bitbucket</span>
22277          <span class="auth-badge hmac">HMAC</span>
22278          <span class="ep-desc">Bitbucket push event receiver</span>
22279          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22280        </div>
22281        <div class="ep-body">
22282          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
22283          <p class="params-heading">Required Headers</p>
22284          <table class="params">
22285            <tr><th>Header</th><th>Value</th></tr>
22286            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
22287            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22288          </table>
22289        </div>
22290      </div>
22291    </div>
22292
22293    <!-- Config -->
22294    <div class="section">
22295      <h2 class="section-title">Config Import / Export</h2>
22296
22297      <div class="ep-card">
22298        <div class="ep-header">
22299          <span class="method get">GET</span>
22300          <span class="ep-path">/export-config</span>
22301          <span class="auth-badge protected">Protected</span>
22302          <span class="ep-desc">Export server configuration as JSON</span>
22303          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22304        </div>
22305        <div class="ep-body">
22306          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
22307          <p class="curl-heading">Example</p>
22308          <div class="curl-wrap">
22309            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22310  -o config.json \
22311  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
22312            <button class="curl-copy-btn" data-target="c-export">Copy</button>
22313          </div>
22314        </div>
22315      </div>
22316
22317      <div class="ep-card">
22318        <div class="ep-header">
22319          <span class="method post">POST</span>
22320          <span class="ep-path">/import-config</span>
22321          <span class="auth-badge protected">Protected</span>
22322          <span class="ep-desc">Import server configuration</span>
22323          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22324        </div>
22325        <div class="ep-body">
22326          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
22327          <p class="curl-heading">Example</p>
22328          <div class="curl-wrap">
22329            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
22330  -H "Authorization: Bearer $SLOC_API_KEY" \
22331  -H "Content-Type: application/json" \
22332  -d @config.json \
22333  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
22334            <button class="curl-copy-btn" data-target="c-import">Copy</button>
22335          </div>
22336        </div>
22337      </div>
22338    </div>
22339
22340    <!-- CI Ingest -->
22341    <div class="section">
22342      <h2 class="section-title">CI Ingest</h2>
22343
22344      <div class="ep-card">
22345        <div class="ep-header">
22346          <span class="method post">POST</span>
22347          <span class="ep-path">/api/ingest</span>
22348          <span class="auth-badge protected">Protected</span>
22349          <span class="ep-desc">Push a pre-computed scan result from CI</span>
22350          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22351        </div>
22352        <div class="ep-body">
22353          <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>
22354          <p class="params-heading">Query Parameters</p>
22355          <table class="params">
22356            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22357            <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>
22358          </table>
22359          <p class="params-heading">Request Body (application/json)</p>
22360          <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>
22361          <details class="schema"><summary>Response schema</summary>
22362<div class="schema-block">// 201 Created
22363{
22364  "run_id":   string,  // UUID of the ingested run
22365  "view_url": string   // relative URL to the report page
22366}</div></details>
22367          <p class="curl-heading">Example</p>
22368          <div class="curl-wrap">
22369            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
22370  -H "Authorization: Bearer $SLOC_API_KEY" \
22371  -H "Content-Type: application/json" \
22372  -d @result.json \
22373  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
22374            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
22375          </div>
22376        </div>
22377      </div>
22378    </div>
22379
22380    <!-- Artifact Download -->
22381    <div class="section">
22382      <h2 class="section-title">Artifact Download</h2>
22383
22384      <div class="ep-card">
22385        <div class="ep-header">
22386          <span class="method get">GET</span>
22387          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
22388          <span class="auth-badge protected">Protected</span>
22389          <span class="ep-desc">Download or view a scan artifact</span>
22390          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22391        </div>
22392        <div class="ep-body">
22393          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
22394          <p class="params-heading">Path Parameters</p>
22395          <table class="params">
22396            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22397            <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>
22398            <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>
22399          </table>
22400          <p class="params-heading">Query Parameters</p>
22401          <table class="params">
22402            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22403            <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>
22404          </table>
22405          <p class="curl-heading">Example — download JSON result</p>
22406          <div class="curl-wrap">
22407            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22408  -o result.json \
22409  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
22410            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
22411          </div>
22412        </div>
22413      </div>
22414    </div>
22415
22416    <!-- Embed Widget -->
22417    <div class="section">
22418      <h2 class="section-title">Embed Widget</h2>
22419
22420      <div class="ep-card">
22421        <div class="ep-header">
22422          <span class="method get">GET</span>
22423          <span class="ep-path">/embed/summary</span>
22424          <span class="auth-badge protected">Protected</span>
22425          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
22426          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22427        </div>
22428        <div class="ep-body">
22429          <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>
22430          <p class="params-heading">Query Parameters</p>
22431          <table class="params">
22432            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22433            <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>
22434            <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>
22435          </table>
22436          <p class="curl-heading">Example</p>
22437          <div class="curl-wrap">
22438            <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"
22439        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
22440            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
22441          </div>
22442        </div>
22443      </div>
22444    </div>
22445
22446    <!-- Confluence Integration -->
22447    <div class="section">
22448      <h2 class="section-title">Confluence Integration</h2>
22449
22450      <div class="ep-card">
22451        <div class="ep-header">
22452          <span class="method get">GET</span>
22453          <span class="ep-path">/api/confluence/config</span>
22454          <span class="auth-badge protected">Protected</span>
22455          <span class="ep-desc">Get current Confluence configuration</span>
22456          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22457        </div>
22458        <div class="ep-body">
22459          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
22460          <details class="schema"><summary>Response schema</summary>
22461<div class="schema-block">{
22462  "configured":     boolean,
22463  "tier":           "cloud" | "server",
22464  "base_url":       string,
22465  "username":       string,
22466  "api_token_set":  boolean,
22467  "space_key":      string,
22468  "parent_page_id": string | null,
22469  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
22470}</div></details>
22471          <p class="curl-heading">Example</p>
22472          <div class="curl-wrap">
22473            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22474  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22475            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
22476          </div>
22477        </div>
22478      </div>
22479
22480      <div class="ep-card">
22481        <div class="ep-header">
22482          <span class="method post">POST</span>
22483          <span class="ep-path">/api/confluence/config</span>
22484          <span class="auth-badge protected">Protected</span>
22485          <span class="ep-desc">Save Confluence configuration</span>
22486          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22487        </div>
22488        <div class="ep-body">
22489          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
22490          <p class="params-heading">Request Body (application/json)</p>
22491          <table class="params">
22492            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22493            <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>
22494            <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>
22495            <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>
22496            <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>
22497            <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>
22498            <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>
22499            <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>
22500          </table>
22501          <details class="schema"><summary>Response schema</summary>
22502<div class="schema-block">{ "ok": true }</div></details>
22503          <p class="curl-heading">Example</p>
22504          <div class="curl-wrap">
22505            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
22506  -H "Authorization: Bearer $SLOC_API_KEY" \
22507  -H "Content-Type: application/json" \
22508  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
22509  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22510            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
22511          </div>
22512        </div>
22513      </div>
22514
22515      <div class="ep-card">
22516        <div class="ep-header">
22517          <span class="method post">POST</span>
22518          <span class="ep-path">/api/confluence/test</span>
22519          <span class="auth-badge protected">Protected</span>
22520          <span class="ep-desc">Test Confluence connection</span>
22521          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22522        </div>
22523        <div class="ep-body">
22524          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
22525          <details class="schema"><summary>Response schema</summary>
22526<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
22527          <p class="curl-heading">Example</p>
22528          <div class="curl-wrap">
22529            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
22530  -H "Authorization: Bearer $SLOC_API_KEY" \
22531  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
22532            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
22533          </div>
22534        </div>
22535      </div>
22536
22537      <div class="ep-card">
22538        <div class="ep-header">
22539          <span class="method post">POST</span>
22540          <span class="ep-path">/api/confluence/post</span>
22541          <span class="auth-badge protected">Protected</span>
22542          <span class="ep-desc">Publish a scan report to Confluence</span>
22543          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22544        </div>
22545        <div class="ep-body">
22546          <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>
22547          <p class="params-heading">Request Body (application/json)</p>
22548          <table class="params">
22549            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22550            <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>
22551            <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>
22552            <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>
22553          </table>
22554          <details class="schema"><summary>Response schema</summary>
22555<div class="schema-block">// 200 OK
22556{ "ok": true, "page_id": string }
22557
22558// 400 / 502 on error
22559{ "ok": false, "error": string }</div></details>
22560          <p class="curl-heading">Example</p>
22561          <div class="curl-wrap">
22562            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
22563  -H "Authorization: Bearer $SLOC_API_KEY" \
22564  -H "Content-Type: application/json" \
22565  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
22566  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
22567            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
22568          </div>
22569        </div>
22570      </div>
22571
22572      <div class="ep-card">
22573        <div class="ep-header">
22574          <span class="method get">GET</span>
22575          <span class="ep-path">/api/confluence/wiki-markup</span>
22576          <span class="auth-badge protected">Protected</span>
22577          <span class="ep-desc">Get Confluence wiki markup for a run</span>
22578          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22579        </div>
22580        <div class="ep-body">
22581          <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>
22582          <p class="params-heading">Query Parameters</p>
22583          <table class="params">
22584            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22585            <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>
22586          </table>
22587          <p class="curl-heading">Example</p>
22588          <div class="curl-wrap">
22589            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22590  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
22591            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
22592          </div>
22593        </div>
22594      </div>
22595    </div>
22596
22597    <!-- Authentication -->
22598    <div class="section">
22599      <h2 class="section-title">Authentication</h2>
22600      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
22601
22602      <div class="ep-card">
22603        <div class="ep-header">
22604          <span class="method get">GET</span>
22605          <span class="ep-path">/auth/login</span>
22606          <span class="auth-badge public">Public</span>
22607          <span class="ep-desc">Login page</span>
22608          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22609        </div>
22610        <div class="ep-body">
22611          <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>
22612          <p class="params-heading">Query Parameters</p>
22613          <table class="params">
22614            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22615            <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>
22616            <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>
22617          </table>
22618        </div>
22619      </div>
22620
22621      <div class="ep-card">
22622        <div class="ep-header">
22623          <span class="method post">POST</span>
22624          <span class="ep-path">/auth/login</span>
22625          <span class="auth-badge public">Public</span>
22626          <span class="ep-desc">Submit credentials and get a session cookie</span>
22627          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22628        </div>
22629        <div class="ep-body">
22630          <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>
22631          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
22632          <table class="params">
22633            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22634            <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>
22635            <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>
22636          </table>
22637          <p class="curl-heading">Example</p>
22638          <div class="curl-wrap">
22639            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
22640  -d "key=$SLOC_API_KEY&amp;next=/" \
22641  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
22642            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
22643          </div>
22644        </div>
22645      </div>
22646    </div>
22647
22648    <!-- Coverage Suggestion -->
22649    <div class="section">
22650      <h2 class="section-title">Coverage Suggestion</h2>
22651
22652      <div class="ep-card">
22653        <div class="ep-header">
22654          <span class="method get">GET</span>
22655          <span class="ep-path">/api/suggest-coverage</span>
22656          <span class="auth-badge protected">Protected</span>
22657          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
22658          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22659        </div>
22660        <div class="ep-body">
22661          <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>
22662          <p class="params-heading">Query Parameters</p>
22663          <table class="params">
22664            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22665            <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>
22666          </table>
22667          <details class="schema"><summary>Response schema</summary>
22668<div class="schema-block">{
22669  "found": string | null,  // absolute path to the coverage file, if detected
22670  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
22671  "hint":  string | null   // shell command to generate coverage if not found
22672}</div></details>
22673          <p class="curl-heading">Example</p>
22674          <div class="curl-wrap">
22675            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22676  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
22677            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
22678          </div>
22679        </div>
22680      </div>
22681    </div>
22682
22683  </div>
22684
22685  <footer class="site-footer">
22686    local code analysis - metrics, history and reports
22687    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22688    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22689    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22690    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22691    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22692  </footer>
22693
22694  <script nonce="{{ csp_nonce }}">
22695    (function () {
22696      var base = window.location.origin;
22697      document.getElementById('base-url').textContent = base;
22698      document.querySelectorAll('.base-url-slot').forEach(function (el) {
22699        el.textContent = base;
22700      });
22701
22702      document.querySelectorAll('.ep-header').forEach(function (hdr) {
22703        hdr.addEventListener('click', function () {
22704          hdr.closest('.ep-card').classList.toggle('open');
22705        });
22706      });
22707
22708      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
22709        btn.addEventListener('click', function () {
22710          var targetId = btn.dataset.target;
22711          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
22712          if (!pre) return;
22713          navigator.clipboard.writeText(pre.textContent).then(function () {
22714            btn.textContent = 'Copied!';
22715            btn.classList.add('copied');
22716            setTimeout(function () {
22717              btn.textContent = 'Copy';
22718              btn.classList.remove('copied');
22719            }, 2000);
22720          });
22721        });
22722      });
22723
22724      var storageKey = 'oxide-sloc-theme';
22725      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
22726      var themeBtn = document.getElementById('theme-toggle');
22727      if (themeBtn) {
22728        themeBtn.addEventListener('click', function () {
22729          var dark = document.body.classList.toggle('dark-theme');
22730          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
22731        });
22732      }
22733      (function() {
22734        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'}];
22735        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);});}
22736        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22737        var btn=document.getElementById('settings-btn');if(!btn)return;
22738        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22739        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>';
22740        document.body.appendChild(m);
22741        var g=document.getElementById('scheme-grid');
22742        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);});
22743        var cl=document.getElementById('settings-close');
22744        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);
22745        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');});
22746        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22747        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22748      })();
22749      (function randomizeWatermarks() {
22750        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22751        if (!wms.length) return;
22752        var placed = [];
22753        function tooClose(top, left) {
22754          for (var i = 0; i < placed.length; i++) {
22755            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22756            if (dt < 16 && dl < 12) return true;
22757          }
22758          return false;
22759        }
22760        function pick(leftBand) {
22761          for (var attempt = 0; attempt < 50; attempt++) {
22762            var top = Math.random() * 88 + 2;
22763            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22764            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22765          }
22766          var top = Math.random() * 88 + 2;
22767          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22768          placed.push([top, left]); return [top, left];
22769        }
22770        var half = Math.floor(wms.length / 2);
22771        wms.forEach(function (img, i) {
22772          var pos = pick(i < half);
22773          var size = Math.floor(Math.random() * 100 + 120);
22774          var rot = (Math.random() * 360).toFixed(1);
22775          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22776          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;
22777        });
22778      })();
22779      (function spawnCodeParticles() {
22780        var container = document.getElementById('code-particles');
22781        if (!container) return;
22782        var snippets = [
22783          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22784          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22785          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22786          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22787          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22788        ];
22789        var count = 38;
22790        for (var i = 0; i < count; i++) {
22791          (function(idx) {
22792            var el = document.createElement('span');
22793            el.className = 'code-particle';
22794            el.textContent = snippets[idx % snippets.length];
22795            var left = Math.random() * 94 + 2;
22796            var top = Math.random() * 88 + 6;
22797            var dur = (Math.random() * 10 + 9).toFixed(1);
22798            var delay = (Math.random() * 18).toFixed(1);
22799            var rot = (Math.random() * 26 - 13).toFixed(1);
22800            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22801            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
22802            container.appendChild(el);
22803          })(i);
22804        }
22805      })();
22806    }());
22807  </script>
22808</body>
22809</html>
22810"##,
22811    ext = "html"
22812)]
22813struct ApiDocsTemplate {
22814    has_api_key: bool,
22815    csp_nonce: String,
22816    version: &'static str,
22817}