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        style_summary: None,
10042    }
10043}
10044
10045pub(crate) fn sanitize_project_label(raw: &str) -> String {
10046    let candidate = Path::new(raw)
10047        .file_name()
10048        .and_then(|name| name.to_str())
10049        .unwrap_or("project");
10050
10051    let mut value = String::with_capacity(candidate.len());
10052    for ch in candidate.chars() {
10053        if ch.is_ascii_alphanumeric() {
10054            value.push(ch.to_ascii_lowercase());
10055        } else {
10056            value.push('-');
10057        }
10058    }
10059
10060    let compact = value.trim_matches('-').to_string();
10061    if compact.is_empty() {
10062        "project".to_string()
10063    } else {
10064        compact
10065    }
10066}
10067
10068/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
10069/// comparisons with non-canonicalized stored paths work correctly.
10070fn strip_unc_prefix(path: PathBuf) -> PathBuf {
10071    let s = path.to_string_lossy();
10072    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
10073        return PathBuf::from(format!(r"\\{rest}"));
10074    }
10075    if let Some(rest) = s.strip_prefix(r"\\?\") {
10076        return PathBuf::from(rest);
10077    }
10078    path
10079}
10080
10081/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
10082/// commit page URL for the most common hosting platforms.
10083fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
10084    let base = if let Some(rest) = remote.strip_prefix("git@") {
10085        let (host, path) = rest.split_once(':')?;
10086        format!("https://{}/{}", host, path.trim_end_matches(".git"))
10087    } else if remote.starts_with("https://") || remote.starts_with("http://") {
10088        remote
10089            .trim_end_matches('/')
10090            .trim_end_matches(".git")
10091            .to_owned()
10092    } else {
10093        return None;
10094    };
10095    let base = base.trim_end_matches('/');
10096    // GitLab uses /-/commit/; everything else uses /commit/
10097    if base.contains("gitlab.com") || base.contains("gitlab.") {
10098        Some(format!("{}/-/commit/{}", base, sha))
10099    } else if base.contains("bitbucket.org") {
10100        Some(format!("{}/commits/{}", base, sha))
10101    } else {
10102        Some(format!("{}/commit/{}", base, sha))
10103    }
10104}
10105
10106fn display_path(path: &Path) -> String {
10107    let s = path.to_string_lossy();
10108    // Strip Windows extended-length prefix for display only; the underlying
10109    // PathBuf remains unchanged so file operations are unaffected.
10110    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
10111    // \\?\C:\path           →  C:\path          (local drive)
10112    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
10113        return format!(r"\\{rest}");
10114    }
10115    if let Some(rest) = s.strip_prefix(r"\\?\") {
10116        return rest.to_owned();
10117    }
10118    s.into_owned()
10119}
10120
10121fn sanitize_path_str(s: &str) -> String {
10122    // Forward-slash variants of the Windows extended-length prefix that appear
10123    // when paths stored as plain strings have been processed through some path
10124    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
10125    if let Some(rest) = s.strip_prefix("//?/UNC/") {
10126        return format!("//{rest}");
10127    }
10128    if let Some(rest) = s.strip_prefix("//?/") {
10129        return rest.to_owned();
10130    }
10131    display_path(Path::new(s))
10132}
10133
10134fn workspace_root() -> PathBuf {
10135    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
10136    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
10137        let p = PathBuf::from(root);
10138        if p.is_dir() {
10139            return p;
10140        }
10141    }
10142
10143    // Current working directory — works for `cargo run` from the project root
10144    // and for scripts/run.sh which cds there first.
10145    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
10146}
10147
10148/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
10149fn make_git_label(repo: &str, ref_name: &str) -> String {
10150    if repo.is_empty() || ref_name.is_empty() {
10151        return String::new();
10152    }
10153    let base = repo
10154        .trim_end_matches('/')
10155        .trim_end_matches(".git")
10156        .rsplit('/')
10157        .next()
10158        .unwrap_or("repo");
10159    let ref_safe: String = ref_name
10160        .chars()
10161        .map(|c| {
10162            if c.is_alphanumeric() || c == '-' || c == '.' {
10163                c
10164            } else {
10165                '_'
10166            }
10167        })
10168        .collect();
10169    format!("{base}_at_{ref_safe}_sloc")
10170}
10171
10172/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
10173fn desktop_dir() -> PathBuf {
10174    if let Ok(profile) = std::env::var("USERPROFILE") {
10175        let p = PathBuf::from(profile).join("Desktop");
10176        if p.exists() {
10177            return p;
10178        }
10179    }
10180    if let Ok(home) = std::env::var("HOME") {
10181        let p = PathBuf::from(home).join("Desktop");
10182        if p.exists() {
10183            return p;
10184        }
10185    }
10186    workspace_root().join("out").join("web")
10187}
10188
10189fn resolve_input_path(raw: &str) -> PathBuf {
10190    let trimmed = raw.trim();
10191    if trimmed.is_empty() {
10192        return workspace_root().join("samples").join("basic");
10193    }
10194
10195    let candidate = PathBuf::from(trimmed);
10196    let resolved = if candidate.is_absolute() {
10197        candidate
10198    } else {
10199        let rooted = workspace_root().join(&candidate);
10200        if rooted.exists() {
10201            rooted
10202        } else {
10203            workspace_root().join(candidate)
10204        }
10205    };
10206
10207    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
10208    // strip that prefix so stored paths and the displayed "Project path" are clean.
10209    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
10210    PathBuf::from(display_path(&canonical))
10211}
10212
10213fn dir_size_bytes(path: &Path) -> u64 {
10214    let mut total = 0u64;
10215    if let Ok(rd) = fs::read_dir(path) {
10216        for entry in rd.filter_map(Result::ok) {
10217            let p = entry.path();
10218            if p.is_file() {
10219                if let Ok(meta) = p.metadata() {
10220                    total += meta.len();
10221                }
10222            } else if p.is_dir() {
10223                total += dir_size_bytes(&p);
10224            }
10225        }
10226    }
10227    total
10228}
10229
10230#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
10231fn format_dir_size(bytes: u64) -> String {
10232    if bytes >= 1_073_741_824 {
10233        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
10234    } else if bytes >= 1_048_576 {
10235        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
10236    } else if bytes >= 1_024 {
10237        format!("{:.0} KB", bytes as f64 / 1_024.0)
10238    } else {
10239        format!("{bytes} B")
10240    }
10241}
10242
10243fn render_submodule_chips(
10244    root: &Path,
10245    submodules: &[(String, std::path::PathBuf)],
10246    out: &mut String,
10247) {
10248    use std::fmt::Write as _;
10249    let count = submodules.len();
10250    out.push_str(r#"<div class="submodule-preview-strip">"#);
10251    write!(
10252        out,
10253        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>"#,
10254        if count == 1 { "" } else { "s" }
10255    )
10256    .ok();
10257    out.push_str(r#"<div class="submodule-preview-chips">"#);
10258    for (sub_name, sub_rel_path) in submodules {
10259        let sub_abs = root.join(sub_rel_path);
10260        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
10261        let mut sub_stats = PreviewStats::default();
10262        let mut sub_rows: Vec<PreviewRow> = Vec::new();
10263        let mut sub_langs: Vec<&'static str> = Vec::new();
10264        let mut sub_budget = PreviewBudget {
10265            shown: 0,
10266            max_entries: 2000,
10267            max_depth: 9,
10268        };
10269        let mut sub_next_id = 1usize;
10270        let _ = collect_preview_rows(
10271            &sub_abs,
10272            &sub_abs,
10273            0,
10274            None,
10275            &mut sub_next_id,
10276            &mut sub_budget,
10277            &mut sub_stats,
10278            &mut sub_rows,
10279            &mut sub_langs,
10280            &[],
10281            &[],
10282        );
10283        let stats_json = format!(
10284            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
10285            sub_stats.directories,
10286            sub_stats.files,
10287            sub_stats.supported,
10288            sub_stats.skipped,
10289            sub_stats.unsupported
10290        );
10291        write!(
10292            out,
10293            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>"#,
10294            escape_html(sub_name),
10295            escape_html(&sub_rel_path.to_string_lossy()),
10296            escape_html(&sub_size),
10297            escape_html(&stats_json),
10298            escape_html(sub_name),
10299            escape_html(&sub_size),
10300        )
10301        .ok();
10302    }
10303    out.push_str(
10304        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
10305    );
10306    out.push_str(r"</div>");
10307}
10308
10309fn render_language_pills_row(languages: &[&str], out: &mut String) {
10310    use std::fmt::Write as _;
10311    if languages.is_empty() {
10312        out.push_str(
10313            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
10314        );
10315        return;
10316    }
10317    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
10318    for language in languages {
10319        if let Some(icon) = language_icon_file(language) {
10320            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();
10321        } else if let Some(svg) = language_inline_svg(language) {
10322            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();
10323        } else {
10324            write!(
10325                out,
10326                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
10327                escape_html(&language.to_ascii_lowercase()),
10328                escape_html(language)
10329            )
10330            .ok();
10331        }
10332    }
10333}
10334
10335#[allow(clippy::too_many_lines)]
10336fn build_preview_html(
10337    root: &Path,
10338    include_patterns: &[String],
10339    exclude_patterns: &[String],
10340) -> Result<String> {
10341    if !root.exists() {
10342        return Ok(format!(
10343            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
10344            escape_html(&display_path(root))
10345        ));
10346    }
10347
10348    let _selected = display_path(root);
10349    let mut stats = PreviewStats::default();
10350    let mut rows = Vec::new();
10351    let mut languages = Vec::new();
10352    let mut budget = PreviewBudget {
10353        shown: 0,
10354        max_entries: 600,
10355        max_depth: 9,
10356    };
10357    let mut next_row_id = 1usize;
10358
10359    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
10360        || root.to_string_lossy().into_owned(),
10361        std::string::ToString::to_string,
10362    );
10363    let root_modified = root
10364        .metadata()
10365        .ok()
10366        .and_then(|meta| meta.modified().ok())
10367        .map_or_else(|| "-".to_string(), format_system_time);
10368
10369    rows.push(PreviewRow {
10370        row_id: 0,
10371        parent_row_id: None,
10372        depth: 0,
10373        name: format!("{root_name}/"),
10374        kind: PreviewKind::Dir,
10375        is_dir: true,
10376        language: None,
10377        modified: root_modified,
10378        type_label: "Directory".to_string(),
10379    });
10380    collect_preview_rows(
10381        root,
10382        root,
10383        0,
10384        Some(0),
10385        &mut next_row_id,
10386        &mut budget,
10387        &mut stats,
10388        &mut rows,
10389        &mut languages,
10390        include_patterns,
10391        exclude_patterns,
10392    )?;
10393
10394    let root_size = format_dir_size(dir_size_bytes(root));
10395
10396    let mut out = String::new();
10397    write!(
10398        out,
10399        r#"<div class="explorer-wrap" data-project-size="{}">"#,
10400        escape_html(&root_size)
10401    )
10402    .ok();
10403    out.push_str(r#"<div class="explorer-toolbar compact">"#);
10404    out.push_str(r#"<div class="explorer-title-group">"#);
10405    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
10406    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
10407    out.push_str(r"</div></div>");
10408
10409    out.push_str(r#"<div class="scope-stats">"#);
10410    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();
10411    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();
10412    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();
10413    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();
10414    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();
10415    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>"#);
10416    out.push_str(r"</div>");
10417
10418    let submodules = sloc_core::detect_submodules(root);
10419    if !submodules.is_empty() {
10420        render_submodule_chips(root, &submodules, &mut out);
10421    }
10422
10423    out.push_str(r#"<div class="scope-info-row">"#);
10424    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
10425    render_language_pills_row(&languages, &mut out);
10426    out.push_str(r"</div></div>");
10427    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>"#);
10428    out.push_str(r"</div>");
10429
10430    out.push_str(r#"<div class="file-explorer-shell">"#);
10431    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>"#);
10432    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>"#);
10433    out.push_str(r#"<div class="file-explorer-tree">"#);
10434    for row in rows {
10435        let status_label = row.kind.label();
10436        let lang_attr = row.language.unwrap_or("");
10437        let toggle_html = if row.is_dir {
10438            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
10439                .to_string()
10440        } else {
10441            r#"<span class="tree-bullet">•</span>"#.to_string()
10442        };
10443        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();
10444    }
10445    if budget.shown >= budget.max_entries {
10446        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>"#);
10447    }
10448    out.push_str(r"</div></div></div>");
10449
10450    Ok(out)
10451}
10452
10453#[derive(Default)]
10454struct PreviewStats {
10455    directories: usize,
10456    files: usize,
10457    supported: usize,
10458    skipped: usize,
10459    unsupported: usize,
10460}
10461
10462struct PreviewRow {
10463    row_id: usize,
10464    parent_row_id: Option<usize>,
10465    depth: usize,
10466    name: String,
10467    kind: PreviewKind,
10468    is_dir: bool,
10469    language: Option<&'static str>,
10470    modified: String,
10471    type_label: String,
10472}
10473
10474#[derive(Copy, Clone)]
10475enum PreviewKind {
10476    Dir,
10477    Supported,
10478    Skipped,
10479    Unsupported,
10480}
10481
10482impl PreviewKind {
10483    const fn filter_key(self) -> &'static str {
10484        match self {
10485            Self::Dir => "dir",
10486            Self::Supported => "supported",
10487            Self::Skipped => "skipped",
10488            Self::Unsupported => "unsupported",
10489        }
10490    }
10491
10492    const fn label(self) -> &'static str {
10493        match self {
10494            Self::Dir => "dir",
10495            Self::Supported => "supported",
10496            Self::Skipped => "skipped by policy",
10497            Self::Unsupported => "unsupported",
10498        }
10499    }
10500
10501    const fn badge_class(self) -> &'static str {
10502        match self {
10503            Self::Dir => "badge badge-dir",
10504            Self::Supported => "badge badge-scan",
10505            Self::Skipped => "badge badge-skip",
10506            Self::Unsupported => "badge badge-unsupported",
10507        }
10508    }
10509
10510    const fn node_class(self) -> &'static str {
10511        match self {
10512            Self::Dir => "tree-node-dir",
10513            Self::Supported => "tree-node-supported",
10514            Self::Skipped => "tree-node-skipped",
10515            Self::Unsupported => "tree-node-unsupported",
10516        }
10517    }
10518}
10519
10520struct PreviewBudget {
10521    shown: usize,
10522    max_entries: usize,
10523    max_depth: usize,
10524}
10525
10526/// Handle a single directory entry inside `collect_preview_rows`.
10527/// Returns `true` when the entry was handled (caller should `continue`).
10528#[allow(clippy::too_many_arguments)]
10529fn handle_preview_dir_entry(
10530    root: &Path,
10531    path: &Path,
10532    name: &str,
10533    modified: String,
10534    depth: usize,
10535    parent_row_id: Option<usize>,
10536    row_id: usize,
10537    next_row_id: &mut usize,
10538    budget: &mut PreviewBudget,
10539    stats: &mut PreviewStats,
10540    rows: &mut Vec<PreviewRow>,
10541    languages: &mut Vec<&'static str>,
10542    include_patterns: &[String],
10543    exclude_patterns: &[String],
10544) -> Result<()> {
10545    let relative = preview_relative_path(root, path);
10546    if should_skip_preview_directory(&relative, exclude_patterns) {
10547        return Ok(());
10548    }
10549    stats.directories += 1;
10550    rows.push(PreviewRow {
10551        row_id,
10552        parent_row_id,
10553        depth: depth + 1,
10554        name: format!("{name}/"),
10555        kind: PreviewKind::Dir,
10556        is_dir: true,
10557        language: None,
10558        modified,
10559        type_label: "Directory".to_string(),
10560    });
10561    budget.shown += 1;
10562    if !matches!(name, ".git" | "node_modules" | "target") {
10563        collect_preview_rows(
10564            root,
10565            path,
10566            depth + 1,
10567            Some(row_id),
10568            next_row_id,
10569            budget,
10570            stats,
10571            rows,
10572            languages,
10573            include_patterns,
10574            exclude_patterns,
10575        )?;
10576    }
10577    Ok(())
10578}
10579
10580/// Handle a single file entry inside `collect_preview_rows`.
10581#[allow(clippy::too_many_arguments)]
10582fn handle_preview_file_entry(
10583    root: &Path,
10584    path: &Path,
10585    name: &str,
10586    modified: String,
10587    depth: usize,
10588    parent_row_id: Option<usize>,
10589    row_id: usize,
10590    budget: &mut PreviewBudget,
10591    stats: &mut PreviewStats,
10592    rows: &mut Vec<PreviewRow>,
10593    languages: &mut Vec<&'static str>,
10594    include_patterns: &[String],
10595    exclude_patterns: &[String],
10596) {
10597    let relative = preview_relative_path(root, path);
10598    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
10599        return;
10600    }
10601    stats.files += 1;
10602    let kind = classify_preview_file(name);
10603    match kind {
10604        PreviewKind::Supported => stats.supported += 1,
10605        PreviewKind::Skipped => stats.skipped += 1,
10606        PreviewKind::Unsupported => stats.unsupported += 1,
10607        PreviewKind::Dir => {}
10608    }
10609    let language = detect_language_name(name);
10610    if let Some(lang) = language {
10611        if !languages.contains(&lang) {
10612            languages.push(lang);
10613        }
10614    }
10615    rows.push(PreviewRow {
10616        row_id,
10617        parent_row_id,
10618        depth: depth + 1,
10619        name: name.to_owned(),
10620        kind,
10621        is_dir: false,
10622        language,
10623        modified,
10624        type_label: preview_type_label(name, language, kind),
10625    });
10626    budget.shown += 1;
10627}
10628
10629#[allow(clippy::too_many_arguments)]
10630#[allow(clippy::too_many_lines)]
10631fn collect_preview_rows(
10632    root: &Path,
10633    dir: &Path,
10634    depth: usize,
10635    parent_row_id: Option<usize>,
10636    next_row_id: &mut usize,
10637    budget: &mut PreviewBudget,
10638    stats: &mut PreviewStats,
10639    rows: &mut Vec<PreviewRow>,
10640    languages: &mut Vec<&'static str>,
10641    include_patterns: &[String],
10642    exclude_patterns: &[String],
10643) -> Result<()> {
10644    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
10645        return Ok(());
10646    }
10647
10648    let mut entries = fs::read_dir(dir)
10649        .with_context(|| format!("failed to read directory {}", dir.display()))?
10650        .filter_map(std::result::Result::ok)
10651        .collect::<Vec<_>>();
10652    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
10653
10654    for entry in entries {
10655        if budget.shown >= budget.max_entries {
10656            break;
10657        }
10658
10659        let path = entry.path();
10660        let name = entry.file_name().to_string_lossy().into_owned();
10661        let Ok(metadata) = entry.metadata() else {
10662            continue;
10663        };
10664        let row_id = *next_row_id;
10665        *next_row_id += 1;
10666        let modified = metadata
10667            .modified()
10668            .ok()
10669            .map_or_else(|| "-".to_string(), format_system_time);
10670
10671        if metadata.is_dir() {
10672            handle_preview_dir_entry(
10673                root,
10674                &path,
10675                &name,
10676                modified,
10677                depth,
10678                parent_row_id,
10679                row_id,
10680                next_row_id,
10681                budget,
10682                stats,
10683                rows,
10684                languages,
10685                include_patterns,
10686                exclude_patterns,
10687            )?;
10688            continue;
10689        }
10690
10691        if metadata.is_file() {
10692            handle_preview_file_entry(
10693                root,
10694                &path,
10695                &name,
10696                modified,
10697                depth,
10698                parent_row_id,
10699                row_id,
10700                budget,
10701                stats,
10702                rows,
10703                languages,
10704                include_patterns,
10705                exclude_patterns,
10706            );
10707        }
10708    }
10709
10710    Ok(())
10711}
10712
10713fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
10714    if let Some(language) = language {
10715        return format!("{language} source");
10716    }
10717    let lower = name.to_ascii_lowercase();
10718    let ext = Path::new(&lower)
10719        .extension()
10720        .and_then(|e| e.to_str())
10721        .unwrap_or("");
10722    match kind {
10723        PreviewKind::Skipped => {
10724            if lower.ends_with(".min.js") {
10725                "Minified asset".to_string()
10726            } else if [
10727                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
10728            ]
10729            .contains(&ext)
10730            {
10731                "Binary or archive".to_string()
10732            } else {
10733                "Skipped file".to_string()
10734            }
10735        }
10736        PreviewKind::Unsupported => {
10737            if ext.is_empty() {
10738                "Unsupported file".to_string()
10739            } else {
10740                format!("{} file", ext.to_ascii_uppercase())
10741            }
10742        }
10743        PreviewKind::Supported => "Supported source".to_string(),
10744        PreviewKind::Dir => "Directory".to_string(),
10745    }
10746}
10747
10748fn format_system_time(time: SystemTime) -> String {
10749    #[allow(clippy::cast_possible_wrap)]
10750    let secs = match time.duration_since(UNIX_EPOCH) {
10751        Ok(duration) => duration.as_secs() as i64,
10752        Err(_) => return "-".to_string(),
10753    };
10754    let days = secs.div_euclid(86_400);
10755    let secs_of_day = secs.rem_euclid(86_400);
10756    let (year, month, day) = civil_from_days(days);
10757    let hour = secs_of_day / 3_600;
10758    let minute = (secs_of_day % 3_600) / 60;
10759    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
10760}
10761
10762#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
10763fn civil_from_days(days: i64) -> (i32, u32, u32) {
10764    let z = days + 719_468;
10765    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
10766    let doe = z - era * 146_097;
10767    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
10768    let y = yoe + era * 400;
10769    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
10770    let mp = (5 * doy + 2) / 153;
10771    let d = doy - (153 * mp + 2) / 5 + 1;
10772    let m = mp + if mp < 10 { 3 } else { -9 };
10773    let year = y + i64::from(m <= 2);
10774    (year as i32, m as u32, d as u32)
10775}
10776
10777// The input is already lowercased via `to_ascii_lowercase()` before calling
10778// `ends_with`, so the comparisons are inherently case-insensitive.
10779#[allow(clippy::case_sensitive_file_extension_comparisons)]
10780fn detect_language_name(name: &str) -> Option<&'static str> {
10781    let lower = name.to_ascii_lowercase();
10782    if lower.ends_with(".c") || lower.ends_with(".h") {
10783        Some("C")
10784    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
10785        .iter()
10786        .any(|s| lower.ends_with(s))
10787    {
10788        Some("C++")
10789    } else if lower.ends_with(".cs") {
10790        Some("C#")
10791    } else if lower.ends_with(".py") {
10792        Some("Python")
10793    } else if lower.ends_with(".sh") {
10794        Some("Shell")
10795    } else if [".ps1", ".psm1", ".psd1"]
10796        .iter()
10797        .any(|s| lower.ends_with(s))
10798    {
10799        Some("PowerShell")
10800    } else {
10801        None
10802    }
10803}
10804
10805fn language_icon_file(language: &str) -> Option<&'static str> {
10806    match language {
10807        "C" => Some("c.png"),
10808        "C++" => Some("cpp.png"),
10809        "C#" => Some("c-sharp.png"),
10810        "Python" => Some("python.png"),
10811        "Shell" => Some("shell.png"),
10812        "PowerShell" => Some("powershell.png"),
10813        "JavaScript" => Some("java-script.png"),
10814        "HTML" => Some("html-5.png"),
10815        "Java" => Some("java.png"),
10816        "Visual Basic" => Some("visual-basic.png"),
10817        "Assembly" => Some("asm.png"),
10818        "Go" => Some("go.png"),
10819        "R" => Some("r.png"),
10820        "XML" => Some("xml.png"),
10821        "Groovy" => Some("groovy.png"),
10822        "Dockerfile" => Some("docker.png"),
10823        "Makefile" => Some("makefile.svg"),
10824        "Perl" => Some("perl.svg"),
10825        _ => None,
10826    }
10827}
10828
10829// Inline SVG badges for languages that have no PNG icon in images/icons/.
10830// Using inline SVG keeps the web UI fully self-contained — no extra files
10831// needed on disk, no 404s on air-gapped deployments.
10832// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
10833fn language_inline_svg(language: &str) -> Option<&'static str> {
10834    match language {
10835        "Rust" => Some(
10836            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>"##,
10837        ),
10838        "TypeScript" => Some(
10839            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>"##,
10840        ),
10841        _ => None,
10842    }
10843}
10844
10845// The input is already lowercased via `to_ascii_lowercase()` before the
10846// `ends_with` calls, so these comparisons are inherently case-insensitive.
10847#[allow(clippy::case_sensitive_file_extension_comparisons)]
10848fn classify_preview_file(name: &str) -> PreviewKind {
10849    let lower = name.to_ascii_lowercase();
10850
10851    let scannable = [
10852        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
10853        ".psm1", ".psd1",
10854    ]
10855    .iter()
10856    .any(|suffix| lower.ends_with(suffix));
10857
10858    if scannable {
10859        PreviewKind::Supported
10860    } else if lower.ends_with(".min.js")
10861        || lower.ends_with(".lock")
10862        || lower.ends_with(".png")
10863        || lower.ends_with(".jpg")
10864        || lower.ends_with(".jpeg")
10865        || lower.ends_with(".gif")
10866        || lower.ends_with(".zip")
10867        || lower.ends_with(".pdf")
10868        || lower.ends_with(".pyc")
10869        || lower.ends_with(".xz")
10870        || lower.ends_with(".tar")
10871        || lower.ends_with(".gz")
10872    {
10873        PreviewKind::Skipped
10874    } else {
10875        PreviewKind::Unsupported
10876    }
10877}
10878
10879fn preview_relative_path(root: &Path, path: &Path) -> String {
10880    path.strip_prefix(root)
10881        .ok()
10882        .unwrap_or(path)
10883        .to_string_lossy()
10884        .replace('\\', "/")
10885        .trim_matches('/')
10886        .to_string()
10887}
10888
10889fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
10890    if relative.is_empty() {
10891        return false;
10892    }
10893
10894    exclude_patterns.iter().any(|pattern| {
10895        wildcard_match(pattern, relative)
10896            || wildcard_match(pattern, &format!("{relative}/"))
10897            || wildcard_match(pattern, &format!("{relative}/placeholder"))
10898    })
10899}
10900
10901fn should_include_preview_file(
10902    relative: &str,
10903    include_patterns: &[String],
10904    exclude_patterns: &[String],
10905) -> bool {
10906    if relative.is_empty() {
10907        return true;
10908    }
10909
10910    let included = include_patterns.is_empty()
10911        || include_patterns
10912            .iter()
10913            .any(|pattern| wildcard_match(pattern, relative));
10914    let excluded = exclude_patterns
10915        .iter()
10916        .any(|pattern| wildcard_match(pattern, relative));
10917
10918    included && !excluded
10919}
10920
10921fn wildcard_match(pattern: &str, candidate: &str) -> bool {
10922    let pattern = pattern.trim().replace('\\', "/");
10923    let candidate = candidate.trim().replace('\\', "/");
10924    let p = pattern.as_bytes();
10925    let c = candidate.as_bytes();
10926    let mut pi = 0usize;
10927    let mut ci = 0usize;
10928    let mut star: Option<usize> = None;
10929    let mut star_match = 0usize;
10930
10931    while ci < c.len() {
10932        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
10933            pi += 1;
10934            ci += 1;
10935        } else if pi < p.len() && p[pi] == b'*' {
10936            while pi < p.len() && p[pi] == b'*' {
10937                pi += 1;
10938            }
10939            star = Some(pi);
10940            star_match = ci;
10941        } else if let Some(star_pi) = star {
10942            star_match += 1;
10943            ci = star_match;
10944            pi = star_pi;
10945        } else {
10946            return false;
10947        }
10948    }
10949
10950    while pi < p.len() && p[pi] == b'*' {
10951        pi += 1;
10952    }
10953
10954    pi == p.len()
10955}
10956
10957fn escape_html(value: &str) -> String {
10958    value
10959        .replace('&', "&amp;")
10960        .replace('<', "&lt;")
10961        .replace('>', "&gt;")
10962        .replace('"', "&quot;")
10963        .replace('\'', "&#39;")
10964}
10965
10966#[derive(Clone)]
10967struct SubmoduleRow {
10968    name: String,
10969    relative_path: String,
10970    files_analyzed: u64,
10971    code_lines: u64,
10972    comment_lines: u64,
10973    blank_lines: u64,
10974    total_physical_lines: u64,
10975    html_url: Option<String>,
10976}
10977
10978#[derive(Template)]
10979#[template(
10980    source = r##"
10981<!doctype html>
10982<html lang="en">
10983<head>
10984  <meta charset="utf-8">
10985  <title>OxideSLOC | tmp-sloc</title>
10986  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10987  <style nonce="{{ csp_nonce }}">
10988    :root {
10989      --bg: #efe9e2;
10990      --surface: #fcfaf7;
10991      --surface-2: #f7f0e8;
10992      --surface-3: #efe3d5;
10993      --line: #dfcfbf;
10994      --line-strong: #cfb29c;
10995      --text: #2f241c;
10996      --muted: #6f6257;
10997      --muted-2: #917f71;
10998      --nav: #b85d33;
10999      --nav-2: #7a371b;
11000      --accent: #2563eb;
11001      --accent-2: #1d4ed8;
11002      --oxide: #b85d33;
11003      --oxide-2: #8f4220;
11004      --success-bg: #eaf9ee;
11005      --success-text: #1c8746;
11006      --warn-bg: #fff2d8;
11007      --warn-text: #926000;
11008      --danger-bg: #fdeaea;
11009      --danger-text: #b33b3b;
11010      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
11011      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
11012      --radius: 14px;
11013    }
11014
11015    body.dark-theme {
11016      --bg: #1b1511;
11017      --surface: #261c17;
11018      --surface-2: #2d221d;
11019      --surface-3: #372922;
11020      --line: #524238;
11021      --line-strong: #6c5649;
11022      --text: #f5ece6;
11023      --muted: #c7b7aa;
11024      --muted-2: #aa9485;
11025      --nav: #b85d33;
11026      --nav-2: #7a371b;
11027      --accent: #6f9bff;
11028      --accent-2: #4a78ee;
11029      --oxide: #d37a4c;
11030      --oxide-2: #b35428;
11031      --success-bg: #163927;
11032      --success-text: #8fe2a8;
11033      --warn-bg: #3c2d11;
11034      --warn-text: #f3cb75;
11035      --danger-bg: #3d1f1f;
11036      --danger-text: #ff9f9f;
11037      --shadow: 0 14px 28px rgba(0,0,0,0.28);
11038      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
11039    }
11040
11041    * { box-sizing: border-box; }
11042    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); }
11043    html { overflow-y: scroll; }
11044    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
11045    .top-nav, .page, .loading { position: relative; z-index: 2; }
11046    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
11047    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
11048    .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); }
11049    .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; }
11050    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
11051    .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)); }
11052    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
11053    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
11054    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
11055    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
11056    .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; }
11057    .nav-project-pill.visible { display:inline-flex; }
11058    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
11059    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
11060    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
11061    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
11062    @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; } }
11063    .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; }
11064    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
11065    .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; }
11066    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
11067    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
11068    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
11069    .theme-toggle .icon-sun { display:none; }
11070    body.dark-theme .theme-toggle .icon-sun { display:block; }
11071    body.dark-theme .theme-toggle .icon-moon { display:none; }
11072    .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;}
11073    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
11074    .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);}
11075    .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;}
11076    .settings-close:hover{color:var(--text);background:var(--surface-2);}
11077    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
11078    .settings-modal-body{padding:14px 16px 16px;}
11079    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
11080    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
11081    .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;}
11082    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
11083    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
11084    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
11085    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
11086    .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;}
11087    .tz-select:focus{border-color:var(--oxide);}
11088    .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; }
11089    .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;}
11090    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
11091    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
11092    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
11093    .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; }
11094    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
11095    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
11096    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
11097    .wb-stats-header { padding: 10px 24px 0; }
11098    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
11099    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
11100    .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; }
11101    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
11102    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
11103    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
11104    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
11105    .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; }
11106    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
11107    .ws-stat-analyzers { position: relative; }
11108    .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; }
11109    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
11110    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
11111    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
11112    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
11113    .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; }
11114    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
11115    .ws-divider { display: none; }
11116    .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%; }
11117    .ws-path-link:hover { color:var(--oxide); }
11118    body.dark-theme .ws-path-link { color:var(--oxide); }
11119    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
11120    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
11121    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
11122    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
11123    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
11124    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
11125    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
11126    .ws-mini-box-lg { flex:2 1 0; }
11127    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
11128    .ws-mini-box-br { flex:1.5 1 0; }
11129    .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); }
11130    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
11131    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
11132    #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; }
11133    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
11134    .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; }
11135    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
11136    .git-source-banner strong { font-weight:800; color:var(--text); }
11137    .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; }
11138    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
11139    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
11140    .git-source-banner a:hover { text-decoration:underline; }
11141    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
11142    .path-scope-sep { background:var(--line); margin:4px 14px; }
11143    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
11144    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
11145    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
11146    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
11147    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
11148    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
11149    .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; }
11150    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
11151    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
11152    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
11153    .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; }
11154    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
11155    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
11156    [data-wb-tip] { cursor:help; }
11157    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
11158    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
11159    .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; }
11160    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
11161    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
11162    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
11163    .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; }
11164    .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); }
11165    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
11166    .side-info-card { padding: 18px; }
11167    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
11168    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
11169    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
11170    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
11171    .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); }
11172    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
11173    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
11174    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
11175    .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; }
11176    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
11177    .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; }
11178    .side-stack::-webkit-scrollbar { display: none; }
11179    .step-nav { padding: 20px 16px; }
11180    .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); }
11181    .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; }
11182    .step-button:hover { background: var(--surface-2); }
11183    .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); }
11184    .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; }
11185    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
11186    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
11187    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
11188    .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); }
11189    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
11190    .step-nav-sum-row:last-child { border-bottom:none; }
11191    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
11192    .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; }
11193    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
11194    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
11195    .quick-scan-section { padding: 10px 4px 14px; }
11196    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
11197    .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; }
11198    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
11199    .quick-scan-btn:active { transform:translateY(0); }
11200    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
11201    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
11202    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
11203    @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);} }
11204    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
11205    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
11206    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
11207    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
11208    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
11209    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
11210    .step-button.done .step-check { opacity:1; }
11211    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
11212    .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; }
11213    .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; }
11214    .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; }
11215    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
11216    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
11217    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
11218    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
11219    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
11220    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
11221    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
11222    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
11223    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
11224    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
11225    .card-body { padding: 22px; }
11226    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
11227    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
11228    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
11229    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
11230    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
11231    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
11232    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
11233    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
11234    .field { min-width:0; }
11235    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
11236    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; }
11237    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); }
11238    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
11239    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); }
11240    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
11241    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
11242    .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; }
11243    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
11244    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
11245    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
11246    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
11247    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
11248    .input-group.compact { grid-template-columns: 1fr auto auto; }
11249    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
11250    .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)); }
11251    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
11252    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
11253    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
11254    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
11255    .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; }
11256    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
11257    .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; }
11258    .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); }
11259    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
11260    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
11261    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
11262    button.secondary { background: var(--surface); }
11263    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11264    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
11265    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
11266    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11267    .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); }
11268    .section + .wizard-actions { border-top: none; padding-top: 0; }
11269    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
11270    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11271    .field-help-grid.coupled-help { margin-top: 12px; }
11272    .field-help-grid.preset-grid { align-items: start; }
11273    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
11274    .preset-inline-row .field { margin: 0; }
11275    .preset-inline-row .explainer-card { margin: 0; }
11276    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
11277    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
11278    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
11279    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
11280    .preset-kv-row > :last-child { flex:1; min-width:0; }
11281    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
11282    .output-field-row .field { margin: 0; }
11283    .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; }
11284    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
11285    .step3-subtitle { margin-bottom: 10px; max-width: none; }
11286    .counting-intro { margin-bottom: 8px; max-width: none; }
11287    .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; }
11288    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
11289    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
11290    .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; }
11291    .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; }
11292    .section-spacer-top { margin-top: 28px; }
11293    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
11294    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
11295    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
11296    .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); }
11297    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
11298    .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; }
11299    .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; }
11300    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
11301    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11302    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
11303    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
11304    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
11305    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
11306    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
11307    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
11308    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
11309    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
11310    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
11311    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
11312    .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); }
11313    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
11314    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
11315    .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; }
11316    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
11317    .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; }
11318    .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; }
11319    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
11320    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
11321    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
11322    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
11323    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
11324    .advanced-rule-description strong { color: var(--text); }
11325    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
11326    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
11327    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
11328    .review-link:hover { text-decoration: underline; }
11329    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
11330    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
11331    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
11332    .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; }
11333    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
11334    .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
11335    .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
11336    body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
11337    .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
11338    body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
11339    .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; }
11340    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
11341    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
11342    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
11343    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11344    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
11345    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
11346    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
11347    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
11348    .review-card ul { padding-left: 18px; margin: 0; }
11349    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
11350    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
11351    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
11352    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
11353    .review-card { min-height: 0; }
11354    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
11355    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
11356    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
11357    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
11358    .lang-overflow-chip { position:relative; cursor:default; }
11359    .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; }
11360    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
11361    .git-inline-row { align-items:start; }
11362    .mixed-line-card { display:flex; flex-direction:column; }
11363    .preset-inline-row .toggle-card { justify-content: center; }
11364        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
11365    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
11366    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
11367    .explorer-title { font-size: 18px; font-weight: 850; }
11368    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
11369    .explorer-subtitle.wide { max-width: none; }
11370    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
11371    .better-spacing { align-items:flex-start; justify-content:flex-end; }
11372    .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; }
11373    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
11374    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
11375    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
11376    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
11377    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
11378    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
11379    .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; }
11380    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
11381    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
11382    .scope-stat-button.supported { background: var(--success-bg); }
11383    .scope-stat-button.skipped { background: var(--warn-bg); }
11384    .scope-stat-button.unsupported { background: var(--danger-bg); }
11385    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
11386    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
11387    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
11388    [data-tooltip] { position: relative; }
11389    [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); }
11390    [data-tooltip]:hover::after { display: block; }
11391    .scope-stat-button[data-tooltip] { cursor: pointer; }
11392    .badge[data-tooltip] { cursor: help; }
11393    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
11394    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
11395    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
11396    .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; }
11397    .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; }
11398    code { display:inline-block; margin-top:0; padding:2px 7px; }
11399    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11400    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
11401    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
11402    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
11403    .language-pill.muted-pill { color: var(--muted); }
11404    button.language-pill { appearance:none; cursor:pointer; }
11405    .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); }
11406    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
11407    .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; }
11408    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
11409    .file-explorer-search-row { margin-left: auto; }
11410    .explorer-filter-select { min-width: 170px; width: 170px; }
11411    .explorer-search { min-width: 300px; width: 300px; }
11412    .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); }
11413    .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; }
11414    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
11415    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
11416    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
11417    .file-explorer-tree { max-height: 640px; overflow:auto; }
11418    .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); }
11419    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
11420    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
11421    .tree-row.hidden-by-filter { display:none !important; }
11422    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
11423    .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; }
11424    .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; }
11425    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
11426    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
11427    .tree-node { display:inline-flex; align-items:center; min-width:0; }
11428    .tree-node-dir { color: var(--text); font-weight: 800; }
11429    .tree-node-supported { color: var(--success-text); }
11430    .tree-node-skipped { color: var(--warn-text); }
11431    .tree-node-unsupported { color: var(--danger-text); }
11432    .tree-node-more { color: var(--muted-2); font-style: italic; }
11433    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
11434    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
11435    .tree-status-cell { display:flex; justify-content:flex-start; }
11436    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
11437    .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; }
11438    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
11439    .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; }
11440    @keyframes prevSpin { to { transform:rotate(360deg); } }
11441    .preview-loading-text { flex:1; min-width:0; }
11442    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
11443    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
11444    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
11445    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
11446    .cov-scan-idle { display:none; }
11447    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
11448    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
11449    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
11450    .cov-scan-title { font-weight:600; font-size:12.5px; }
11451    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
11452    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
11453    .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; }
11454    .cov-scan-use:hover { opacity:.75; }
11455    .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; }
11456    .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; }
11457    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
11458    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
11459    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
11460    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
11461    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
11462    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
11463    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
11464    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
11465    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
11466    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
11467    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
11468    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
11469    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
11470    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
11471    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
11472    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
11473    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
11474    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
11475    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
11476    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
11477    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
11478    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
11479    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
11480    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
11481    .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); }
11482    .loading.active { display:flex; }
11483    .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; }
11484    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
11485    .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; }
11486    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
11487    .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; }
11488    .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; }
11489    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
11490    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
11491    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
11492    .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; }
11493    .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
11494    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
11495    .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
11496    .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
11497    .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; }
11498    .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; }
11499    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
11500    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
11501    .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; }
11502    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
11503    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
11504    .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; }
11505    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
11506    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
11507    .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; }
11508    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
11509    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
11510    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
11511    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
11512    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
11513    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
11514    .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; }
11515    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
11516    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
11517    .hidden { display:none !important; }
11518    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11519    .site-footer a{color:var(--muted);}
11520    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
11521    @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; } }
11522    .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;}
11523    @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));}}
11524    .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;}
11525    .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; }
11526    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
11527    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
11528    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
11529    .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; }
11530    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
11531    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
11532    .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; }
11533    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
11534    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
11535    .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; }
11536    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
11537    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
11538    .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; }
11539    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
11540    .info-icon-btn:hover { color:var(--text); }
11541    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); }
11542    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
11543    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
11544    .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;}
11545    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
11546    .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;}
11547    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
11548  </style>
11549</head>
11550<body>
11551  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" />
11566  </div>
11567  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11568  <div class="top-nav">
11569    <div class="top-nav-inner">
11570      <a class="brand" href="/">
11571        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
11572        <div class="brand-copy">
11573          <div class="brand-title">OxideSLOC</div>
11574          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
11575        </div>
11576      </a>
11577      <div class="nav-project-slot">
11578        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
11579          <span class="nav-project-label">Project</span>
11580          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
11581        </div>
11582      </div>
11583      <div class="nav-status">
11584        <a class="nav-pill" href="/">Home</a>
11585        <div class="nav-dropdown">
11586          <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>
11587          <div class="nav-dropdown-menu">
11588            <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>
11589          </div>
11590        </div>
11591        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11592        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11593        <div class="nav-dropdown">
11594          <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>
11595          <div class="nav-dropdown-menu">
11596            <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>
11597          </div>
11598        </div>
11599        <div class="server-status-wrap" id="server-status-wrap">
11600          <div class="nav-pill server-online-pill" id="server-status-pill">
11601            <span class="status-dot" id="status-dot"></span>
11602            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
11603            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11604          </div>
11605          <div class="server-status-tip">
11606            {% if server_mode %}
11607            OxideSLOC is running in server mode — accessible on your LAN.
11608            {% else %}
11609            OxideSLOC is running locally — only accessible from this machine.
11610            {% endif %}
11611            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11612          </div>
11613        </div>
11614        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11615          <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>
11616        </button>
11617        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
11618          <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>
11619          <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>
11620        </button>
11621      </div>
11622    </div>
11623  </div>
11624
11625  <div class="loading" id="loading">
11626    <div class="loading-card">
11627      <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
11628      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
11629      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
11630      <div class="lc-path" id="lc-path"></div>
11631      <div class="lc-metrics" id="lc-metrics">
11632        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
11633        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
11634        <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>
11635      </div>
11636      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
11637      <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>
11638      <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>
11639      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
11640      <div class="lc-actions hidden" id="lc-actions">
11641        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
11642        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
11643      </div>
11644      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
11645        <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>
11646        Cancel scan
11647      </button>
11648    </div>
11649  </div>
11650
11651  <div class="page">
11652    <div class="workbench-strip">
11653      <div class="workbench-box wb-stats">
11654        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
11655          <span class="wb-stats-title">Analysis session</span>
11656        </div>
11657        <div class="ws-left">
11658          <div class="ws-stat ws-stat-analyzers">
11659            <span class="ws-label">Analyzers</span>
11660            <span class="ws-value">
11661              <span class="ws-badge">41 languages</span>
11662            </span>
11663            <div class="ws-lang-tooltip">
11664              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
11665              <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>
11666              <div class="ws-lang-grid">
11667                <span class="ws-lang-item">Assembly</span>
11668                <span class="ws-lang-item">C</span>
11669                <span class="ws-lang-item">C++</span>
11670                <span class="ws-lang-item">C#</span>
11671                <span class="ws-lang-item">Clojure</span>
11672                <span class="ws-lang-item">CSS</span>
11673                <span class="ws-lang-item">Dart</span>
11674                <span class="ws-lang-item">Dockerfile</span>
11675                <span class="ws-lang-item">Elixir</span>
11676                <span class="ws-lang-item">Erlang</span>
11677                <span class="ws-lang-item">F#</span>
11678                <span class="ws-lang-item">Go</span>
11679                <span class="ws-lang-item">Groovy</span>
11680                <span class="ws-lang-item">Haskell</span>
11681                <span class="ws-lang-item">HTML</span>
11682                <span class="ws-lang-item">Java</span>
11683                <span class="ws-lang-item">JavaScript</span>
11684                <span class="ws-lang-item">Julia</span>
11685                <span class="ws-lang-item">Kotlin</span>
11686                <span class="ws-lang-item">Lua</span>
11687                <span class="ws-lang-item">Makefile</span>
11688                <span class="ws-lang-item">Nim</span>
11689                <span class="ws-lang-item">Obj-C</span>
11690                <span class="ws-lang-item">OCaml</span>
11691                <span class="ws-lang-item">Perl</span>
11692                <span class="ws-lang-item">PHP</span>
11693                <span class="ws-lang-item">PowerShell</span>
11694                <span class="ws-lang-item">Python</span>
11695                <span class="ws-lang-item">R</span>
11696                <span class="ws-lang-item">Ruby</span>
11697                <span class="ws-lang-item">Rust</span>
11698                <span class="ws-lang-item">Scala</span>
11699                <span class="ws-lang-item">SCSS</span>
11700                <span class="ws-lang-item">Shell</span>
11701                <span class="ws-lang-item">SQL</span>
11702                <span class="ws-lang-item">Svelte</span>
11703                <span class="ws-lang-item">Swift</span>
11704                <span class="ws-lang-item">TypeScript</span>
11705                <span class="ws-lang-item">Vue</span>
11706                <span class="ws-lang-item">XML</span>
11707                <span class="ws-lang-item">Zig</span>
11708              </div>
11709            </div>
11710          </div>
11711          <div class="ws-divider"></div>
11712          <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>
11713          <div class="ws-divider"></div>
11714          <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.">
11715            <span class="ws-label">Output</span>
11716            <span class="ws-value">
11717              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
11718                <span id="ws-output-root">project/sloc</span>
11719              </button>
11720            </span>
11721          </div>
11722        </div>
11723      </div>
11724      <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.">
11725        <div class="ws-history-label">Scan history</div>
11726        <div class="ws-history-inner">
11727          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
11728            <div class="ws-mini-label">Scans</div>
11729            <div class="ws-mini-value" id="ws-scan-count">—</div>
11730          </div>
11731          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
11732            <div class="ws-mini-label">Last Scan</div>
11733            <div class="ws-mini-value" id="ws-last-scan">—</div>
11734          </div>
11735          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
11736            <div class="ws-mini-label">Branch</div>
11737            <div class="ws-mini-value" id="ws-branch">—</div>
11738          </div>
11739        </div>
11740      </div>
11741    </div>
11742
11743    <div class="layout">
11744      <aside class="side-stack">
11745        <section class="step-nav">
11746        <h3>Guided scan setup</h3>
11747        <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>
11748        <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>
11749        <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>
11750        <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>
11751
11752        <div class="step-steps-divider"></div>
11753
11754        <div class="step-nav-info" id="step-nav-info">
11755          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
11756          <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>
11757        </div>
11758
11759        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
11760          <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>
11761          <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>
11762          <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>
11763        </div>
11764
11765        <div class="quick-scan-divider"></div>
11766        <div class="quick-scan-section">
11767          <div class="quick-scan-label">No customization needed?</div>
11768          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
11769            <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>
11770            Quick Scan
11771          </button>
11772          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
11773        </div>
11774
11775        <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>
11776        </section>
11777
11778      </aside>
11779
11780      <section class="card">
11781        <div class="card-header">
11782          <div class="card-title-row">
11783            <div>
11784              <h1 class="card-title">Guided scan configuration</h1>
11785              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
11786            </div>
11787            <div class="wizard-progress" aria-label="Scan setup progress">
11788              <div class="wizard-progress-top">
11789                <span class="wizard-progress-label">Setup progress</span>
11790                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
11791              </div>
11792              <div class="wizard-progress-track">
11793                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
11794              </div>
11795            </div>
11796          </div>
11797        </div>
11798        <div class="card-body">
11799          <form method="post" action="/analyze" id="analyze-form">
11800            <div class="wizard-step active" data-step="1">
11801              <div class="section">
11802                <div class="section-kicker">Step 1</div>
11803                <h2>Select project and preview scope</h2>
11804                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
11805                <div class="field">
11806                  <label for="path">Project path</label>
11807                  {% if !git_repo.is_empty() %}
11808                  <div class="git-source-banner">
11809                    <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>
11810                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
11811                    <a href="/git-browser">← Back to Git Browser</a>
11812                  </div>
11813                  {% endif %}
11814                  <div class="path-scope-grid">
11815                      {% if !git_repo.is_empty() %}
11816                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
11817                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
11818                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
11819                      {% else %}
11820                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
11821                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
11822                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
11823                      {% endif %}
11824                    <div class="path-scope-sep"></div>
11825                    <div class="scope-legend-row">
11826                      <span class="scope-legend-label">Scope legend:</span>
11827                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
11828                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
11829                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
11830                    </div>
11831                  </div>
11832                  {% if git_repo.is_empty() %}
11833                  {% if server_mode %}
11834                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
11835                    ℹ️ Files are compressed and streamed — no fixed size limit.
11836                  </div>
11837                  {% endif %}
11838                  <div class="path-info-row">
11839                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
11840                      <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>
11841                      <span id="project-size-text">Project size: —</span>
11842                    </button>
11843                  </div>
11844                  {% else %}
11845                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
11846                  {% endif %}
11847                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
11848                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
11849                </div>
11850
11851                <div class="scope-preview-divider" aria-hidden="true"></div>
11852
11853                <div id="preview-panel">
11854                  <div class="preview-error">Loading preview...</div>
11855                </div>
11856              </div>
11857
11858              <div class="section" style="margin-top:14px;">
11859                <div class="preset-inline-row git-inline-row">
11860                  <div class="toggle-card" style="margin:0;">
11861                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
11862                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
11863                    <label class="checkbox">
11864                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
11865                      <div>
11866                        <span>Detect and separate git submodules</span>
11867                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
11868                      </div>
11869                    </label>
11870                  </div>
11871                  <div class="explainer-card prominent" style="margin:0;">
11872                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11873                    <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>
11874                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
11875    path = libs/core
11876    url  = https://github.com/org/core.git
11877
11878[submodule "libs/ui"]
11879    path = libs/ui
11880    url  = https://github.com/org/ui.git</div>
11881                  </div>
11882                </div>
11883              </div>
11884
11885              <div class="section">
11886                <div class="field-grid">
11887                  <div class="field">
11888                    <label for="include_globs">Include globs</label>
11889                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
11890                    <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>
11891                  </div>
11892                  <div class="field">
11893                    <label for="exclude_globs">Exclude globs</label>
11894                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
11895                    <div id="quick-exclude-chips" class="quick-excl-row">
11896                      <span class="quick-excl-label">Quick add:</span>
11897                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
11898                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
11899                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
11900                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
11901                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
11902                      <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>
11903                    </div>
11904                    <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>
11905                  </div>
11906                </div>
11907                <div class="glob-guidance-grid">
11908                  <div class="glob-guidance-card">
11909                    <strong>How to read them</strong>
11910                    <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>
11911                  </div>
11912                  <div class="glob-guidance-card">
11913                    <strong>Common include examples</strong>
11914                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
11915                  </div>
11916                  <div class="glob-guidance-card">
11917                    <strong>Common exclude examples</strong>
11918                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
11919                  </div>
11920                </div>
11921              </div>
11922
11923              <div class="section" style="margin-top:14px;">
11924                <div class="preset-inline-row git-inline-row">
11925                  <div class="toggle-card" style="margin:0;">
11926                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
11927                    <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>
11928                    <div class="field" style="margin:0;">
11929                      <div class="input-group compact">
11930                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
11931                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
11932                      </div>
11933                      <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>
11934                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
11935                    </div>
11936                  </div>
11937                  <div class="explainer-card prominent" style="margin:0;">
11938                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11939                    <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>
11940                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
11941lcov --capture --directory . --output-file coverage/lcov.info
11942
11943# C / C++ — llvm-cov (LCOV)
11944llvm-profdata merge -sparse default.profraw -o default.profdata
11945llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
11946
11947# C# — coverlet (Cobertura XML)
11948dotnet test --collect:"XPlat Code Coverage"
11949
11950# Python — pytest-cov (Cobertura XML)
11951pytest --cov --cov-report=xml
11952
11953# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
11954./gradlew jacocoTestReport</div>
11955                  </div>
11956                </div>
11957              </div>
11958
11959              <div class="wizard-actions">
11960                <div class="left"></div>
11961                <div class="right">
11962                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
11963                </div>
11964              </div>
11965            </div>
11966
11967            <div class="wizard-step" data-step="2">
11968              <div class="section">
11969                <div class="section-kicker">Step 2</div>
11970                <h2>Choose counting behavior</h2>
11971                <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>
11972<div class="subsection-bar">Primary line classification</div>
11973                <div class="preset-kv-row">
11974                  <div class="toggle-card mixed-line-card" style="margin:0;">
11975                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
11976                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
11977                    <select id="mixed_line_policy" name="mixed_line_policy">
11978                      <option value="code_only">Code only</option>
11979                      <option value="code_and_comment">Code and comment</option>
11980                      <option value="comment_only">Comment only</option>
11981                      <option value="separate_mixed_category">Separate mixed category</option>
11982                    </select>
11983                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
11984                  </div>
11985                  <div class="explainer-card prominent" style="margin:0;">
11986                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
11987                    <div class="explainer-body" id="mixed-policy-description"></div>
11988                    <div class="code-sample" id="mixed-policy-example"></div>
11989                  </div>
11990                </div>
11991              </div>
11992
11993              <div class="subsection-bar">Additional scan rules</div>
11994              <div class="scan-rules-grid">
11995                <div class="preset-inline-row">
11996                  <div class="toggle-card" style="margin:0;">
11997                    <div class="field-help-title">Generated files</div>
11998                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
11999                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12000                  </div>
12001                  <div class="explainer-card prominent" style="margin:0;">
12002                    <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>
12003                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
12004# Files matching codegen patterns are excluded:
12005#   *.generated.cs  *.pb.go  *.g.dart</div>
12006                  </div>
12007                </div>
12008                <div class="preset-inline-row">
12009                  <div class="toggle-card" style="margin:0;">
12010                    <div class="field-help-title">Minified files</div>
12011                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
12012                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12013                  </div>
12014                  <div class="explainer-card prominent" style="margin:0;">
12015                    <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>
12016                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
12017# Heuristic: very long lines + low whitespace ratio
12018#   jquery.min.js  bundle.min.css  → skipped</div>
12019                  </div>
12020                </div>
12021                <div class="preset-inline-row">
12022                  <div class="toggle-card" style="margin:0;">
12023                    <div class="field-help-title">Vendor directories</div>
12024                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
12025                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
12026                  </div>
12027                  <div class="explainer-card prominent" style="margin:0;">
12028                    <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>
12029                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
12030# Directories named vendor/ node_modules/ third_party/
12031#   → entire subtree is excluded from totals</div>
12032                  </div>
12033                </div>
12034                <div class="preset-inline-row">
12035                  <div class="toggle-card" style="margin:0;">
12036                    <div class="field-help-title">Lockfiles and manifests</div>
12037                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
12038                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
12039                  </div>
12040                  <div class="explainer-card prominent" style="margin:0;">
12041                    <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>
12042                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
12043# Files like package-lock.json  Cargo.lock  yarn.lock
12044#   → skipped unless this is enabled</div>
12045                  </div>
12046                </div>
12047                <div class="preset-inline-row">
12048                  <div class="toggle-card" style="margin:0;">
12049                    <div class="field-help-title">Binary handling</div>
12050                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
12051                    <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>
12052                  </div>
12053                  <div class="explainer-card prominent" style="margin:0;">
12054                    <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>
12055                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
12056# Detected via long lines + low whitespace heuristic
12057#   .png  .exe  .so  → skipped silently</div>
12058                  </div>
12059                </div>
12060                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
12061                  <div class="toggle-card" style="margin:0;">
12062                    <div class="field-help-title">Python docstrings</div>
12063                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
12064                    <label class="checkbox">
12065                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
12066                      <span>Count as comment-style lines</span>
12067                    </label>
12068                  </div>
12069                  <div class="explainer-card prominent" style="margin:0;">
12070                    <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>
12071                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
12072                  </div>
12073                </div>
12074              </div>
12075              <div class="subsection-bar">IEEE 1045-1992 counting</div>
12076              <div class="scan-rules-grid">
12077                <div class="preset-inline-row">
12078                  <div class="toggle-card" style="margin:0;">
12079                    <div class="field-help-title">Continuation lines</div>
12080                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
12081                    <select name="continuation_line_policy" id="continuation_line_policy">
12082                      <option value="each_physical_line" selected>Each physical line (default)</option>
12083                      <option value="collapse_to_logical">Collapse to logical line</option>
12084                    </select>
12085                  </div>
12086                  <div class="explainer-card prominent" style="margin:0;">
12087                    <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>
12088                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
12089    ((a) &gt; (b) ? (a) : (b))
12090# each_physical_line → 2 SLOC
12091# collapse_to_logical → 1 SLOC</div>
12092                  </div>
12093                </div>
12094                <div class="preset-inline-row">
12095                  <div class="toggle-card" style="margin:0;">
12096                    <div class="field-help-title">Block-comment blanks</div>
12097                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
12098                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
12099                      <option value="count_as_comment" selected>Count as comment (default)</option>
12100                      <option value="count_as_blank">Count as blank</option>
12101                    </select>
12102                  </div>
12103                  <div class="explainer-card prominent" style="margin:0;">
12104                    <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>
12105                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
12106 * Summary line
12107 *              ← blank inside block comment
12108 * Detail line
12109 */
12110# count_as_comment → blank counts toward comments
12111# count_as_blank   → blank counts toward blanks</div>
12112                  </div>
12113                </div>
12114                <div class="preset-inline-row">
12115                  <div class="toggle-card" style="margin:0;">
12116                    <div class="field-help-title">Compiler directives</div>
12117                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
12118                    <select name="count_compiler_directives" id="count_compiler_directives">
12119                      <option value="enabled" selected>Include in code SLOC (default)</option>
12120                      <option value="disabled">Exclude from code SLOC</option>
12121                    </select>
12122                  </div>
12123                  <div class="explainer-card prominent" style="margin:0;">
12124                    <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>
12125                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
12126#define BUF 256     ← compiler directive
12127int main() { … }   ← code
12128# enabled  → 3 code SLOC
12129# disabled → 1 code SLOC + 2 directive lines</div>
12130                  </div>
12131                </div>
12132              </div>
12133
12134              <div class="always-tracked-tip">
12135                <div class="always-tracked-tip-icon">ℹ</div>
12136                <div class="always-tracked-tip-body">
12137                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
12138                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
12139                  <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>
12140                </div>
12141              </div>
12142
12143              <div class="wizard-actions">
12144                <div class="left">
12145                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
12146                </div>
12147                <div class="right">
12148                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
12149                </div>
12150              </div>
12151            </div>
12152
12153            <div class="wizard-step" data-step="3">
12154              <div class="section">
12155                <div class="section-kicker">Step 3</div>
12156                <h2>Output and report identity</h2>
12157                <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>
12158                <div class="preset-kv-row">
12159                  <div class="toggle-card" style="margin:0;">
12160                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
12161                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
12162                    <select id="scan_preset">
12163                      <option value="balanced">Balanced local scan</option>
12164                      <option value="code_focused">Code focused</option>
12165                      <option value="comment_audit">Comment audit</option>
12166                      <option value="deep_review">Deep review</option>
12167                    </select>
12168                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
12169                  </div>
12170                  <div class="explainer-card">
12171                    <div class="field-help-title">Selected scan preset</div>
12172                    <div class="explainer-body" id="scan-preset-description"></div>
12173                    <div class="preset-summary-row" id="scan-preset-summary"></div>
12174                    <div class="code-sample" id="scan-preset-example"></div>
12175                    <div class="preset-note" id="scan-preset-note"></div>
12176                  </div>
12177                </div>
12178                <hr class="step3-separator" />
12179                <div class="preset-kv-row">
12180                  <div class="toggle-card" style="margin:0;">
12181                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
12182                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
12183                    <select id="artifact_preset">
12184                      <option value="review">Review bundle</option>
12185                      <option value="full">Full bundle</option>
12186                      <option value="html_only">HTML only</option>
12187                      <option value="machine">Machine bundle</option>
12188                    </select>
12189                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
12190                  </div>
12191                  <div class="explainer-card">
12192                    <div class="field-help-title">Selected artifact preset</div>
12193                    <div class="explainer-body" id="artifact-preset-description"></div>
12194                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
12195                    <div class="code-sample" id="artifact-preset-example"></div>
12196                  </div>
12197                </div>
12198              </div>
12199
12200              <div class="section section-spacer-top">
12201                <div class="output-field-row">
12202                  <div class="field">
12203                    <label for="output_dir">Output directory</label>
12204                    {% if server_mode %}
12205                    <div class="input-group compact">
12206                      <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);" />
12207                    </div>
12208                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
12209                    {% else %}
12210                    <div class="input-group compact">
12211                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
12212                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
12213                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
12214                    </div>
12215                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
12216                    {% endif %}
12217                  </div>
12218                  <div class="output-field-aside">
12219                    <strong>Where reports land</strong>
12220                    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.
12221                  </div>
12222                </div>
12223              </div>
12224
12225              <div class="section section-spacer-top">
12226                <div class="output-field-row">
12227                  <div class="field">
12228                    <label for="report_title">Report title</label>
12229                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
12230                    <div class="hint">Appears in HTML and PDF output headers.</div>
12231                  </div>
12232                  <div class="output-field-aside">
12233                    <strong>Shown in exported artifacts</strong>
12234                    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.
12235                  </div>
12236                </div>
12237              </div>
12238
12239              <div class="section section-spacer-top">
12240                <div class="output-field-row">
12241                  <div class="field">
12242                    <label for="report_header_footer">Report header / footer</label>
12243                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
12244                    <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>
12245                  </div>
12246                  <div class="output-field-aside">
12247                    <strong>Page-level identification</strong>
12248                    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.
12249                  </div>
12250                </div>
12251              </div>
12252
12253              <div class="section">
12254                <div class="section-kicker">Artifacts</div>
12255                <div class="artifact-grid" style="margin-bottom:24px;">
12256                  <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
12257                    <div class="marker">✓</div>
12258                    <div class="artifact-icon">H</div>
12259                    <h4>HTML report</h4>
12260                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
12261                    <div class="artifact-tags">
12262                      <span class="soft-chip">Best for visual review</span>
12263                      <span class="soft-chip">Embeddable preview</span>
12264                    </div>
12265                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
12266                  </div>
12267                  <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
12268                    <div class="marker">✓</div>
12269                    <div class="artifact-icon">P</div>
12270                    <h4>PDF export</h4>
12271                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
12272                    <div class="artifact-tags">
12273                      <span class="soft-chip">Portable snapshot</span>
12274                      <span class="soft-chip">Good for handoff</span>
12275                    </div>
12276                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
12277                  </div>
12278                  <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
12279                    <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>
12280                    <div class="marker">✓</div>
12281                    <div class="artifact-icon" style="color:var(--muted);">J</div>
12282                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
12283                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
12284                    <div class="artifact-tags">
12285                      <span class="soft-chip">Required for compare</span>
12286                      <span class="soft-chip">Auto-enabled</span>
12287                    </div>
12288                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
12289                  </div>
12290                </div>
12291                <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>
12292              </div>
12293
12294              <div class="wizard-actions">
12295                <div class="left">
12296                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
12297                </div>
12298                <div class="right">
12299                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
12300                </div>
12301              </div>
12302            </div>
12303
12304            <div class="wizard-step" data-step="4">
12305              <div class="section">
12306                <div class="section-kicker">Step 4</div>
12307                <h2>Review selections and run</h2>
12308                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
12309                <div class="review-grid">
12310                  <div class="review-card highlight">
12311                    <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>
12312                    <ul id="review-scan-summary"></ul>
12313                  </div>
12314                  <div class="review-card highlight">
12315                    <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>
12316                    <ul id="review-count-summary"></ul>
12317                  </div>
12318                  <div class="review-card">
12319                    <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>
12320                    <ul id="review-artifact-summary"></ul>
12321                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
12322                  </div>
12323                  <div class="review-card">
12324                    <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>
12325                    <ul id="review-preview-summary"></ul>
12326                  </div>
12327                </div>
12328              </div>
12329
12330              <div class="wizard-actions">
12331                <div class="left">
12332                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
12333                </div>
12334                <div class="right">
12335                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
12336                </div>
12337              </div>
12338            </div>
12339            {% if server_mode %}
12340            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
12341            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
12342            {% endif %}
12343          </form>
12344        </div>
12345      </section>
12346    </div>
12347  </div>
12348
12349  <script nonce="{{ csp_nonce }}">
12350    (function () {
12351      function startScanPhase() {
12352        var phaseEl = document.getElementById("scan-phase");
12353        if (!phaseEl) return;
12354        var phases = [
12355          "Discovering files...",
12356          "Decoding file encodings...",
12357          "Detecting languages...",
12358          "Analyzing source lines...",
12359          "Applying counting policies...",
12360          "Aggregating results...",
12361          "Rendering report..."
12362        ];
12363        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
12364        var i = 0;
12365        function next() {
12366          phaseEl.style.opacity = "0";
12367          setTimeout(function () {
12368            phaseEl.textContent = phases[i];
12369            phaseEl.style.opacity = "0.85";
12370            var delay = durations[i] || 1800;
12371            i++;
12372            if (i < phases.length) { setTimeout(next, delay); }
12373          }, 200);
12374        }
12375        next();
12376      }
12377
12378      var form = document.getElementById("analyze-form");
12379      var loading = document.getElementById("loading");
12380      var submitButton = document.getElementById("submit-button");
12381      var pathInput = document.getElementById("path");
12382      var GIT_MODE = !!(pathInput && pathInput.readOnly);
12383      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
12384      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
12385      var outputDirInput = document.getElementById("output_dir");
12386      var reportTitleInput = document.getElementById("report_title");
12387      var previewPanel = document.getElementById("preview-panel");
12388      var refreshButton = document.getElementById("refresh-preview");
12389      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
12390      var useSamplePath = document.getElementById("use-sample-path");
12391      var useDefaultOutput = document.getElementById("use-default-output");
12392      var browsePath = document.getElementById("browse-path");
12393      var browseOutputDir = document.getElementById("browse-output-dir");
12394      var browseCoverage = document.getElementById("browse-coverage");
12395      var coverageInput = document.getElementById("coverage_file");
12396      var covScanStatus = document.getElementById("cov-scan-status");
12397      var coverageSuggestTimer = null;
12398      var covAutoFilled = false;
12399      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
12400      function fmtBytes(b) {
12401        b = Number(b) || 0;
12402        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
12403        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
12404        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
12405        return b + ' B';
12406      }
12407      var themeToggle = document.getElementById("theme-toggle");
12408
12409      function showBannerToast(msg, isError, opts) {
12410        opts = opts || {};
12411        var t = document.createElement('div');
12412        t.className = isError ? 'toast-error' : 'toast-success';
12413        var topPos = opts.top ? '80px' : null;
12414        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
12415          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
12416          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
12417          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
12418        if (opts.icon) {
12419          var inner = document.createElement('span');
12420          inner.innerHTML = opts.icon + ' ';
12421          t.appendChild(inner);
12422        }
12423        t.appendChild(document.createTextNode(msg));
12424        document.body.appendChild(t);
12425        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
12426      }
12427      var mixedLinePolicy = document.getElementById("mixed_line_policy");
12428      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
12429      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
12430      var scanPreset = document.getElementById("scan_preset");
12431      var artifactPreset = document.getElementById("artifact_preset");
12432      var includeGlobsInput = document.getElementById("include_globs");
12433      var excludeGlobsInput = document.getElementById("exclude_globs");
12434
12435      // Quick-exclude chips — append pattern to exclude_globs textarea.
12436      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
12437        chip.addEventListener("click", function() {
12438          var pattern = chip.getAttribute("data-pattern") || "";
12439          if (!pattern || !excludeGlobsInput) return;
12440          var current = excludeGlobsInput.value.trim();
12441          // For the "skip all" chip, replace any existing dep patterns cleanly.
12442          var patterns = pattern.split("\n");
12443          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
12444          var added = false;
12445          patterns.forEach(function(p) {
12446            p = p.trim();
12447            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
12448          });
12449          if (added) {
12450            excludeGlobsInput.value = lines.join("\n");
12451            excludeGlobsInput.dispatchEvent(new Event("input"));
12452          }
12453          chip.classList.add("active");
12454        });
12455      });
12456
12457      var liveReportTitle = document.getElementById("live-report-title");
12458      var navProjectPill = document.getElementById("nav-project-pill");
12459      var navProjectTitle = document.getElementById("nav-project-title");
12460      var reportTitlePreview = null;
12461      var wizardProgressFill = document.getElementById("wizard-progress-fill");
12462      var wizardProgressValue = document.getElementById("wizard-progress-value");
12463      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
12464      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
12465      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
12466      var reportTitleTouched = false;
12467      var currentStep = 1;
12468      var previewTimer = null;
12469      var quickScanBtn = document.getElementById("quick-scan-btn");
12470
12471      function dismissAnalysisModal() {
12472        if (loading) loading.classList.remove("active");
12473        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12474          var el = document.getElementById(id);
12475          if (el) el.classList.add("hidden");
12476        });
12477        var cancelBtn = document.getElementById("lc-cancel-btn");
12478        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
12479        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
12480        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
12481        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12482        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12483        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12484        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12485        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12486      }
12487
12488      var lcDismissBtn = document.getElementById("lc-dismiss");
12489      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
12490
12491      function startAsyncAnalysis(formData) {
12492        var gitRepo = (formData.get("git_repo") || "").toString();
12493        var gitRef  = (formData.get("git_ref")  || "").toString();
12494        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
12495        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
12496
12497        var pathEl = document.getElementById("lc-path");
12498        if (pathEl) pathEl.textContent = displayPath;
12499
12500        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12501          var el = document.getElementById(id);
12502          if (el) el.classList.add("hidden");
12503        });
12504        var cancelBtn = document.getElementById("lc-cancel-btn");
12505        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
12506        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12507        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12508        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12509        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
12510        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
12511
12512        if (loading) loading.classList.add("active");
12513
12514        var startTime = Date.now();
12515        var elapsedTimer = setInterval(function() {
12516          var s = Math.floor((Date.now() - startTime) / 1000);
12517          var el = document.getElementById("lc-elapsed");
12518          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
12519        }, 1000);
12520
12521        var warnShown = false, pollRetries = 0, activeWaitId = null;
12522
12523        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();}
12524
12525        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
12526
12527        function lcShowCancelled() {
12528          clearInterval(elapsedTimer);
12529          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
12530          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
12531          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
12532          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
12533          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
12534          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
12535          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
12536          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
12537          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12538          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12539        }
12540
12541        var lcCancelBtn = document.getElementById("lc-cancel-btn");
12542        if (lcCancelBtn) {
12543          lcCancelBtn.onclick = function() {
12544            if (!activeWaitId) { dismissAnalysisModal(); return; }
12545            lcCancelBtn.disabled = true;
12546            lcCancelBtn.textContent = "Cancelling…";
12547            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
12548              .then(function() { lcShowCancelled(); })
12549              .catch(function() { lcShowCancelled(); });
12550          };
12551        }
12552
12553        function lcShowError(msg) {
12554          clearInterval(elapsedTimer);
12555          lcSetPhase("Failed");
12556          var msgEl = document.getElementById("lc-err-msg");
12557          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
12558          var errEl = document.getElementById("lc-err");
12559          var actEl = document.getElementById("lc-actions");
12560          if (errEl) errEl.classList.remove("hidden");
12561          if (actEl) actEl.classList.remove("hidden");
12562          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12563          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12564        }
12565
12566        function lcPoll(waitId) {
12567          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
12568            .then(function(r) {
12569              if (!r.ok) throw new Error("HTTP " + r.status);
12570              return r.json();
12571            })
12572            .then(function(data) {
12573              pollRetries = 0;
12574              if (data.state === "complete") {
12575                clearInterval(elapsedTimer);
12576                lcSetPhase("Done");
12577                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
12578              } else if (data.state === "failed") {
12579                lcShowError(data.message);
12580              } else if (data.state === "cancelled") {
12581                lcShowCancelled();
12582              } else {
12583                var s = Math.floor((Date.now() - startTime) / 1000);
12584                if (s > 90 && !warnShown) {
12585                  warnShown = true;
12586                  var w = document.getElementById("lc-warn");
12587                  if (w) w.classList.remove("hidden");
12588                }
12589                lcSetPhase(data.phase || "Running");
12590                var fd = data.files_done || 0, ft = data.files_total || 0;
12591                if (ft > 0) {
12592                  var card = document.getElementById("lc-files-card");
12593                  if (card) card.classList.remove("hidden");
12594                  var el = document.getElementById("lc-files");
12595                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
12596                }
12597                setTimeout(function() { lcPoll(waitId); }, 1500);
12598              }
12599            })
12600            .catch(function() {
12601              pollRetries++;
12602              if (pollRetries >= 5) {
12603                lcShowError("Lost connection to server. Reload to check status.");
12604              } else {
12605                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
12606              }
12607            });
12608        }
12609
12610        var params = new URLSearchParams(formData);
12611        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
12612          .then(function(r) {
12613            var waitId = r.headers.get("x-wait-id");
12614            if (!waitId) { window.location.href = "/scan"; return; }
12615            activeWaitId = waitId;
12616            setTimeout(function() { lcPoll(waitId); }, 1500);
12617          })
12618          .catch(function(err) {
12619            lcShowError("Could not reach server: " + (err.message || err));
12620          });
12621      }
12622
12623      if (quickScanBtn) {
12624        quickScanBtn.addEventListener("click", function () {
12625          var pathVal = pathInput ? pathInput.value.trim() : "";
12626          if (!pathVal) {
12627            alert("Please enter or browse to a project path first.");
12628            return;
12629          }
12630          quickScanBtn.disabled = true;
12631          quickScanBtn.textContent = "Scanning...";
12632          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
12633          startAsyncAnalysis(new FormData(form));
12634        });
12635      }
12636
12637      var mixedPolicyInfo = {
12638        code_only: {
12639          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.",
12640          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'
12641        },
12642        code_and_comment: {
12643          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.",
12644          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'
12645        },
12646        comment_only: {
12647          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.",
12648          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'
12649        },
12650        separate_mixed_category: {
12651          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.",
12652          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'
12653        }
12654      };
12655
12656      var scanPresetInfo = {
12657        balanced: {
12658          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.",
12659          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
12660          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
12661          note: "Best when you want a stable local overview before making deeper adjustments.",
12662          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12663        },
12664        code_focused: {
12665          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
12666          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
12667          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
12668          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
12669          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12670        },
12671        comment_audit: {
12672          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
12673          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
12674          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
12675          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
12676          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12677        },
12678        deep_review: {
12679          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
12680          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
12681          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
12682          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
12683          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
12684        }
12685      };
12686
12687      var artifactPresetInfo = {
12688        review: {
12689          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.",
12690          chips: ["HTML", "PDF"],
12691          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
12692        },
12693        full: {
12694          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.",
12695          chips: ["HTML", "PDF", "JSON"],
12696          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
12697        },
12698        html_only: {
12699          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.",
12700          chips: ["HTML only", "Fast local review"],
12701          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
12702        },
12703        machine: {
12704          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
12705          chips: ["HTML", "JSON"],
12706          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
12707        }
12708      };
12709
12710      function applyTheme(theme) {
12711        if (theme === "dark") document.body.classList.add("dark-theme");
12712        else document.body.classList.remove("dark-theme");
12713      }
12714
12715      function loadSavedTheme() {
12716        var saved = null;
12717        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
12718        applyTheme(saved === "dark" ? "dark" : "light");
12719      }
12720
12721      function updateScrollProgress() {
12722        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
12723        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
12724        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
12725        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
12726        var step = Math.min(Math.max(currentStep, 1), 4);
12727        var base = stepBase[step];
12728        var end  = stepEnd[step];
12729
12730        var scrollFrac = 0;
12731        var activePanel = document.querySelector(".wizard-step.active");
12732        if (activePanel) {
12733          var scrollTop = window.scrollY || window.pageYOffset || 0;
12734          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
12735          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
12736          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
12737          var scrolled = scrollTop + viewH - panelTop;
12738          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
12739        }
12740
12741        var percent = Math.round(base + (end - base) * scrollFrac);
12742        percent = Math.min(end, Math.max(base, percent));
12743        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
12744        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
12745      }
12746
12747      function updateWizardProgress() {
12748        updateScrollProgress();
12749      }
12750
12751      var stepDescriptions = [
12752        "Choose a project folder, apply scope filters, and preview which files will be counted.",
12753        "Configure how mixed code-plus-comment lines and docstrings are classified.",
12754        "Pick your output formats, scan preset, and where reports are saved.",
12755        "Review all settings and launch the analysis."
12756      ];
12757
12758      function updateStepNav(step) {
12759        var infoLabel = document.getElementById("step-nav-info-label");
12760        var infoDesc  = document.getElementById("step-nav-info-desc");
12761        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
12762        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
12763      }
12764
12765      function updateSidebarSummary() {
12766        var sumPath    = document.getElementById("sum-path");
12767        var sumPreset  = document.getElementById("sum-preset");
12768        var sumOutput  = document.getElementById("sum-output");
12769        var sidebarSummary = document.getElementById("sidebar-summary");
12770        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
12771        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
12772        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
12773        if (sumPath)   sumPath.textContent   = pathVal   || "—";
12774        if (sumPreset) sumPreset.textContent = presetVal || "—";
12775        if (sumOutput) sumOutput.textContent = outputVal || "—";
12776        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
12777      }
12778
12779      function setStep(step, pushHistory) {
12780        currentStep = step;
12781        stepPanels.forEach(function (panel) {
12782          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
12783        });
12784        stepButtons.forEach(function (button) {
12785          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
12786        });
12787        var layoutEl = document.querySelector(".layout");
12788        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
12789        updateWizardProgress();
12790        updateStepNav(step);
12791        stepButtons.forEach(function(btn) {
12792          var t = Number(btn.getAttribute("data-step-target"));
12793          btn.classList.toggle("done", t < step);
12794        });
12795        updateSidebarSummary();
12796
12797        if (pushHistory !== false) {
12798          try {
12799            history.pushState({ wizardStep: step }, "", "#step" + step);
12800          } catch (e) {}
12801        }
12802
12803        window.scrollTo({ top: 0, behavior: "instant" });
12804      }
12805
12806      window.addEventListener("popstate", function (e) {
12807        if (e.state && e.state.wizardStep) {
12808          setStep(e.state.wizardStep, false);
12809        } else {
12810          var hashMatch = location.hash.match(/^#step([1-4])$/);
12811          if (hashMatch) setStep(Number(hashMatch[1]), false);
12812        }
12813      });
12814
12815      function inferTitleFromPath(value) {
12816        if (!value) return "project";
12817        var cleaned = value.replace(/[\/\\]+$/, "");
12818        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
12819        return parts.length ? parts[parts.length - 1] : value;
12820      }
12821
12822      function updateReportTitleFromPath() {
12823        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
12824        if (!reportTitleTouched) {
12825          reportTitleInput.value = inferred;
12826        }
12827        var title = reportTitleInput.value || inferred;
12828        if (liveReportTitle) liveReportTitle.textContent = title;
12829        if (reportTitlePreview) reportTitlePreview.textContent = title;
12830        document.title = "OxideSLOC | " + title;
12831
12832        var projectPath = (pathInput.value || "").trim();
12833        if (navProjectPill && navProjectTitle) {
12834          if (projectPath.length > 0) {
12835            navProjectTitle.textContent = inferred;
12836            navProjectPill.classList.add("visible");
12837          } else {
12838            navProjectTitle.textContent = "";
12839            navProjectPill.classList.remove("visible");
12840          }
12841        }
12842      }
12843
12844      function updateMixedPolicyUI() {
12845        var key = mixedLinePolicy.value || "code_only";
12846        var info = mixedPolicyInfo[key];
12847        document.getElementById("mixed-policy-description").textContent = info.description;
12848        document.getElementById("mixed-policy-example").textContent = info.example;
12849      }
12850
12851      function updatePythonDocstringUI() {
12852        var checked = !!pythonDocstrings.checked;
12853        document.getElementById("python-docstring-example").textContent = checked
12854          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
12855          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
12856        document.getElementById("python-docstring-live-help").textContent = checked
12857          ? "Enabled: docstrings contribute to comment-style totals."
12858          : "Disabled: docstrings are not counted as comment content.";
12859      }
12860
12861      function renderPresetChips(targetId, chips) {
12862        var target = document.getElementById(targetId);
12863        if (!target) return;
12864        target.innerHTML = (chips || []).map(function (chip) {
12865          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
12866        }).join('');
12867      }
12868
12869      function updatePresetDescriptions() {
12870        var scanInfo = scanPresetInfo[scanPreset.value];
12871        var artifactInfo = artifactPresetInfo[artifactPreset.value];
12872        document.getElementById("scan-preset-description").textContent = scanInfo.description;
12873        document.getElementById("scan-preset-example").textContent = scanInfo.example;
12874        document.getElementById("scan-preset-note").textContent = scanInfo.note;
12875        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
12876        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
12877        renderPresetChips("scan-preset-summary", scanInfo.chips);
12878        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
12879      }
12880
12881      function applyScanPreset() {
12882        var info = scanPresetInfo[scanPreset.value];
12883        if (!info || !info.apply) return;
12884        mixedLinePolicy.value = info.apply.mixed;
12885        pythonDocstrings.checked = !!info.apply.docstrings;
12886        document.getElementById("generated_file_detection").value = info.apply.generated;
12887        document.getElementById("minified_file_detection").value = info.apply.minified;
12888        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
12889        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
12890        document.getElementById("binary_file_behavior").value = info.apply.binary;
12891        updateMixedPolicyUI();
12892        updatePythonDocstringUI();
12893      }
12894
12895      function applyArtifactPreset() {
12896        var enabled = { html: false, pdf: false };
12897        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
12898        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
12899        if (artifactPreset.value === "html_only") { enabled.html = true; }
12900        if (artifactPreset.value === "machine") { enabled.html = true; }
12901
12902        artifactCards.forEach(function (card) {
12903          var artifact = card.getAttribute("data-artifact");
12904          if (artifact === "json") return;
12905          var checked = !!enabled[artifact];
12906          var checkbox = card.querySelector(".artifact-checkbox");
12907          checkbox.checked = checked;
12908          card.classList.toggle("selected", checked);
12909        });
12910      }
12911
12912      function toggleArtifactCard(card) {
12913        var checkbox = card.querySelector(".artifact-checkbox");
12914        checkbox.checked = !checkbox.checked;
12915        card.classList.toggle("selected", checkbox.checked);
12916      }
12917
12918      function updateReview() {
12919        var scanSummary = document.getElementById("review-scan-summary");
12920        var countSummary = document.getElementById("review-count-summary");
12921        var artifactSummary = document.getElementById("review-artifact-summary");
12922        var outputSummary = document.getElementById("review-output-summary");
12923        var previewSummary = document.getElementById("review-preview-summary");
12924        var readinessSummary = document.getElementById("review-readiness-summary");
12925        var includeText = document.getElementById("include_globs").value.trim();
12926        var excludeText = document.getElementById("exclude_globs").value.trim();
12927        var sidePathPreview = document.getElementById("side-path-preview");
12928        var sideOutputPreview = document.getElementById("side-output-preview");
12929        var sideTitlePreview = document.getElementById("side-title-preview");
12930
12931        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
12932        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
12933        if (sideTitlePreview) {
12934          var rt = document.getElementById("report_title");
12935          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
12936        }
12937
12938        scanSummary.innerHTML = ""
12939          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
12940          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
12941          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
12942
12943        countSummary.innerHTML = ""
12944          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
12945          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
12946          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
12947          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
12948          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
12949          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
12950          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
12951          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
12952
12953        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
12954        artifactSummary.innerHTML = ""
12955          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
12956          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
12957
12958        outputSummary.innerHTML = ""
12959          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
12960          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
12961
12962        if (previewSummary) {
12963          if (GIT_MODE) {
12964            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>';
12965          } else {
12966          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
12967          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
12968          var statMap = {};
12969          statButtons.forEach(function (button) {
12970            var valueNode = button.querySelector('.scope-stat-value');
12971            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
12972          });
12973          previewSummary.innerHTML = ''
12974            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
12975            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
12976            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
12977            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
12978            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
12979            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
12980
12981          if (readinessSummary) {
12982            var selectedArtifactsCount = selectedArtifacts.length;
12983            readinessSummary.innerHTML = ''
12984              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
12985              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
12986              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
12987              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
12988          }
12989          } // end else (non-GIT_MODE)
12990        }
12991      }
12992
12993      function escapeHtml(value) {
12994        return String(value)
12995          .replace(/&/g, "&amp;")
12996          .replace(/</g, "&lt;")
12997          .replace(/>/g, "&gt;")
12998          .replace(/"/g, "&quot;")
12999          .replace(/'/g, "&#39;");
13000      }
13001
13002      function isPythonVisible() {
13003        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
13004      }
13005
13006      function syncPythonVisibility() {
13007        var html = previewPanel.textContent || "";
13008        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
13009        pythonWraps.forEach(function (node) {
13010          node.classList.toggle("hidden", !hasPython);
13011        });
13012      }
13013
13014      function attachPreviewInteractions() {
13015        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
13016        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
13017        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
13018        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
13019        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
13020        var searchInput = previewPanel.querySelector("#explorer-search");
13021        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
13022        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
13023        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
13024        var activeFilter = "all";
13025        var activeLanguage = "";
13026        var searchTerm = "";
13027        var currentSortKey = null;
13028        var currentSortOrder = "asc";
13029        var childRows = {};
13030
13031        rows.forEach(function (row) {
13032          var parentId = row.getAttribute("data-parent-id") || "";
13033          var rowId = row.getAttribute("data-row-id") || "";
13034          if (!childRows[parentId]) childRows[parentId] = [];
13035          childRows[parentId].push(rowId);
13036        });
13037
13038        function rowById(id) {
13039          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
13040        }
13041
13042        function hasCollapsedAncestor(row) {
13043          var parentId = row.getAttribute("data-parent-id");
13044          while (parentId) {
13045            var parent = rowById(parentId);
13046            if (!parent) break;
13047            if (parent.getAttribute("data-expanded") === "false") return true;
13048            parentId = parent.getAttribute("data-parent-id");
13049          }
13050          return false;
13051        }
13052
13053        function updateToggleGlyph(row) {
13054          var toggle = row.querySelector(".tree-toggle");
13055          if (!toggle) return;
13056          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
13057        }
13058
13059        function rowSortValue(row, key) {
13060          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
13061        }
13062
13063        function updateSortButtons() {
13064          sortButtons.forEach(function (button) {
13065            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
13066            var indicator = button.querySelector(".tree-sort-indicator");
13067            button.classList.toggle("active", isActive);
13068            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
13069            if (indicator) {
13070              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
13071            }
13072          });
13073        }
13074
13075        function sortSiblingRows() {
13076          if (!treeContainer) {
13077            updateSortButtons();
13078            return;
13079          }
13080
13081          var rowMap = {};
13082          var childrenMap = {};
13083          rows.forEach(function (row) {
13084            var rowId = row.getAttribute("data-row-id");
13085            var parentId = row.getAttribute("data-parent-id") || "";
13086            rowMap[rowId] = row;
13087            if (!childrenMap[parentId]) childrenMap[parentId] = [];
13088            childrenMap[parentId].push(rowId);
13089          });
13090
13091          Object.keys(childrenMap).forEach(function (parentId) {
13092            if (!parentId) return;
13093            childrenMap[parentId].sort(function (a, b) {
13094              var rowA = rowMap[a];
13095              var rowB = rowMap[b];
13096              if (!currentSortKey) {
13097                return Number(a) - Number(b);
13098              }
13099              var valueA = rowSortValue(rowA, currentSortKey);
13100              var valueB = rowSortValue(rowB, currentSortKey);
13101              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
13102              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
13103              var fallbackA = rowSortValue(rowA, "name");
13104              var fallbackB = rowSortValue(rowB, "name");
13105              if (fallbackA < fallbackB) return -1;
13106              if (fallbackA > fallbackB) return 1;
13107              return Number(a) - Number(b);
13108            });
13109          });
13110
13111          var orderedIds = [];
13112          function pushChildren(parentId) {
13113            (childrenMap[parentId] || []).forEach(function (childId) {
13114              orderedIds.push(childId);
13115              pushChildren(childId);
13116            });
13117          }
13118
13119          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
13120            orderedIds.push(topId);
13121            pushChildren(topId);
13122          });
13123
13124          orderedIds.forEach(function (id) {
13125            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
13126          });
13127          updateSortButtons();
13128        }
13129
13130        function updateLanguageButtons() {
13131          languageButtons.forEach(function (button) {
13132            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
13133            var isActive = languageValue === activeLanguage;
13134            button.classList.toggle("active", isActive);
13135          });
13136        }
13137
13138        function rowSelfMatches(row) {
13139          var kind = row.getAttribute("data-kind");
13140          var status = row.getAttribute("data-status");
13141          var language = (row.getAttribute("data-language") || "").toLowerCase();
13142          var name = row.getAttribute("data-name-lower") || "";
13143          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
13144          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
13145          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
13146          var passesLanguage = !activeLanguage || language === activeLanguage;
13147          return passesFilter && passesSearch && passesLanguage;
13148        }
13149
13150        function hasMatchingDescendant(rowId) {
13151          return (childRows[rowId] || []).some(function (childId) {
13152            var childRow = rowById(childId);
13153            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
13154          });
13155        }
13156
13157        function rowMatches(row) {
13158          if (rowSelfMatches(row)) return true;
13159          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
13160        }
13161
13162        function resetViewState() {
13163          activeFilter = "all";
13164          activeLanguage = "";
13165          searchTerm = "";
13166          currentSortKey = null;
13167          currentSortOrder = "asc";
13168          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13169          if (searchInput) searchInput.value = "";
13170          if (filterSelect) filterSelect.value = "all";
13171          updateLanguageButtons();
13172        }
13173
13174        function applyVisibility() {
13175          rows.forEach(function (row) {
13176            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
13177            row.classList.toggle("hidden-by-filter", !visible);
13178            row.style.display = visible ? "grid" : "none";
13179          });
13180          buttons.forEach(function (button) {
13181            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
13182          });
13183          if (filterSelect) filterSelect.value = activeFilter;
13184        }
13185
13186        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
13187        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
13188        var originalStats = {};
13189        buttons.forEach(function (btn) {
13190          var f = btn.getAttribute('data-filter');
13191          var v = btn.querySelector('.scope-stat-value');
13192          if (f && v) originalStats[f] = v.textContent;
13193        });
13194
13195        function applySubmoduleStats(statsJson) {
13196          try {
13197            var s = JSON.parse(statsJson);
13198            buttons.forEach(function (btn) {
13199              var f = btn.getAttribute('data-filter');
13200              var v = btn.querySelector('.scope-stat-value');
13201              if (!v) return;
13202              if (f === 'dir') v.textContent = s.dirs;
13203              else if (f === 'file') v.textContent = s.files;
13204              else if (f === 'supported') v.textContent = s.supported;
13205              else if (f === 'skipped') v.textContent = s.skipped;
13206              else if (f === 'unsupported') v.textContent = s.unsupported;
13207            });
13208          } catch (e) {}
13209        }
13210
13211        function restoreBaseRepoStats() {
13212          buttons.forEach(function (btn) {
13213            var f = btn.getAttribute('data-filter');
13214            var v = btn.querySelector('.scope-stat-value');
13215            if (v && originalStats[f]) v.textContent = originalStats[f];
13216          });
13217          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
13218          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
13219        }
13220
13221        submoduleChips.forEach(function (chip) {
13222          chip.addEventListener('click', function () {
13223            var statsJson = chip.getAttribute('data-sub-stats');
13224            if (!statsJson) return;
13225            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
13226            chip.classList.add('active');
13227            applySubmoduleStats(statsJson);
13228            if (baseRepoBtn) baseRepoBtn.style.display = '';
13229          });
13230        });
13231
13232        if (baseRepoBtn) {
13233          baseRepoBtn.addEventListener('click', function () {
13234            restoreBaseRepoStats();
13235            resetViewState();
13236            sortSiblingRows();
13237            applyVisibility();
13238          });
13239        }
13240
13241        buttons.forEach(function (button) {
13242          button.addEventListener("click", function () {
13243            var filterValue = button.getAttribute("data-filter") || "all";
13244            if (filterValue === "reset-view") {
13245              restoreBaseRepoStats();
13246              resetViewState();
13247              sortSiblingRows();
13248              applyVisibility();
13249              return;
13250            }
13251            activeFilter = filterValue;
13252            applyVisibility();
13253          });
13254        });
13255
13256        rows.forEach(function (row) {
13257          updateToggleGlyph(row);
13258          var toggle = row.querySelector(".tree-toggle");
13259          if (toggle) {
13260            toggle.addEventListener("click", function () {
13261              var expanded = row.getAttribute("data-expanded") !== "false";
13262              row.setAttribute("data-expanded", expanded ? "false" : "true");
13263              updateToggleGlyph(row);
13264              applyVisibility();
13265            });
13266          }
13267        });
13268
13269        actionButtons.forEach(function (button) {
13270          button.addEventListener("click", function () {
13271            var action = button.getAttribute("data-explorer-action");
13272            if (action === "expand-all") {
13273              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13274            } else if (action === "collapse-all") {
13275              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
13276            } else if (action === "clear-filters") {
13277              resetViewState();
13278            }
13279            sortSiblingRows();
13280            applyVisibility();
13281          });
13282        });
13283
13284        if (filterSelect) {
13285          filterSelect.addEventListener("change", function () {
13286            activeFilter = filterSelect.value || "all";
13287            applyVisibility();
13288          });
13289        }
13290
13291        languageButtons.forEach(function (button) {
13292          button.addEventListener("click", function () {
13293            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
13294            updateLanguageButtons();
13295            applyVisibility();
13296          });
13297        });
13298
13299        sortButtons.forEach(function (button) {
13300          button.addEventListener("click", function () {
13301            var sortKey = button.getAttribute("data-sort-key");
13302            if (currentSortKey === sortKey) {
13303              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
13304            } else {
13305              currentSortKey = sortKey;
13306              currentSortOrder = "asc";
13307            }
13308            sortSiblingRows();
13309            applyVisibility();
13310          });
13311        });
13312
13313        if (searchInput) {
13314          searchInput.addEventListener("input", function () {
13315            searchTerm = searchInput.value.trim().toLowerCase();
13316            applyVisibility();
13317          });
13318        }
13319
13320        updateLanguageButtons();
13321        sortSiblingRows();
13322        applyVisibility();
13323      }
13324
13325      function loadPreview() {
13326        if (!previewPanel || !pathInput) return;
13327        if (GIT_MODE) {
13328          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>';
13329          return;
13330        }
13331        var path = pathInput.value.trim();
13332        var zeroWarn = document.getElementById('zero-files-warning');
13333        if (!path) {
13334          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
13335          if (zeroWarn) zeroWarn.style.display = 'none';
13336          return;
13337        }
13338        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
13339        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
13340        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
13341        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
13342        var _prevMsgs = [
13343          'Scanning directory structure…',
13344          'Detecting file types…',
13345          'Applying include / exclude filters…',
13346          'Estimating file counts…',
13347          'Building scope preview…',
13348          'Almost there…'
13349        ];
13350        var _prevMsgIdx = 0;
13351        var _prevStart = Date.now();
13352        previewPanel.innerHTML =
13353          '<div class="preview-loading">' +
13354          '<div class="preview-spinner"></div>' +
13355          '<div class="preview-loading-text">' +
13356          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
13357          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
13358          '</div></div>';
13359        var _sizeTextEl = document.getElementById('project-size-text');
13360        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
13361        window._previewInterval = setInterval(function() {
13362          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
13363          var ml = document.getElementById('plm');
13364          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
13365        }, 1500);
13366        window._previewElapsedTimer = setInterval(function() {
13367          var el = document.getElementById('ple');
13368          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
13369        }, 1000);
13370        var previewUrl = "/preview?path=" + encodeURIComponent(path)
13371          + "&include_globs=" + encodeURIComponent(includeValue)
13372          + "&exclude_globs=" + encodeURIComponent(excludeValue);
13373        fetch(previewUrl)
13374          .then(function (response) { return response.text(); })
13375          .then(function (html) {
13376            clearInterval(window._previewInterval); window._previewInterval = null;
13377            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
13378            previewPanel.innerHTML = html;
13379            attachPreviewInteractions();
13380            syncPythonVisibility();
13381            updateReview();
13382            setTimeout(collapseLanguagePills, 50);
13383            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
13384            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
13385            var sizeText = document.getElementById('project-size-text');
13386            var sizeBtn = document.getElementById('project-size-btn');
13387            // In server mode with upload sizes available, keep the compressed/original pair.
13388            if (SERVER_MODE && window._lastUploadSizes) {
13389              var us = window._lastUploadSizes;
13390              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
13391                ' · Compressed: ' + fmtBytes(us.compressed_bytes);
13392              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
13393                ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
13394            } else if (sizeText && projectSize) {
13395              sizeText.textContent = 'Project size: ' + projectSize;
13396              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
13397            } else if (sizeText) {
13398              sizeText.textContent = 'Project size: —';
13399            }
13400            if (zeroWarn) {
13401              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
13402              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
13403              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
13404              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
13405              if (supportedCount === 0 && fileCount > 0) {
13406                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).';
13407                zeroWarn.style.display = '';
13408              } else {
13409                zeroWarn.style.display = 'none';
13410              }
13411            }
13412          })
13413          .catch(function (err) {
13414            clearInterval(window._previewInterval); window._previewInterval = null;
13415            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
13416            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
13417          });
13418      }
13419
13420      function pickDirectory(targetInput, kind) {
13421        if (SERVER_MODE) {
13422          if (kind === 'output') {
13423            showBannerToast(
13424              'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
13425              false,
13426              { top: true, icon: '📁' }
13427            );
13428            return;
13429          }
13430          var inputEl = kind === 'coverage'
13431            ? document.getElementById('cov-upload-input')
13432            : document.getElementById('dir-upload-input');
13433          if (!inputEl) return;
13434          inputEl.onchange = function () {
13435            var files = inputEl.files;
13436            if (!files || files.length === 0) return;
13437            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
13438            if (browseBtn) browseBtn.disabled = true;
13439
13440            function fileToBase64(file) {
13441              return new Promise(function (resolve, reject) {
13442                var reader = new FileReader();
13443                reader.onload = function () {
13444                  var b64 = reader.result.split(',')[1];
13445                  resolve(b64);
13446                };
13447                reader.onerror = reject;
13448                reader.readAsDataURL(file);
13449              });
13450            }
13451
13452            if (kind === 'coverage') {
13453              var f = files[0];
13454              if (previewPanel && targetInput === pathInput)
13455                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
13456              fileToBase64(f).then(function (b64) {
13457                return fetch('/api/upload-file', {
13458                  method: 'POST',
13459                  headers: { 'Content-Type': 'application/json' },
13460                  body: JSON.stringify({ filename: f.name, content: b64 })
13461                }).then(function (r) { return r.json(); });
13462              })
13463                .then(function (d) {
13464                  if (d && d.tmp_path) {
13465                    if (coverageInput) coverageInput.value = d.tmp_path;
13466                    setCovStatus('idle');
13467                  } else if (d && d.error) { showBannerToast(d.error, true); }
13468                })
13469                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
13470                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
13471            } else {
13472              // ── Filter to source-code files only ─────────────────────────
13473              // Binary, generated, and dependency files (node_modules, .git,
13474              // build artifacts) are skipped so they are never uploaded.
13475              var CODE_EXTS = new Set([
13476                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13477                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13478                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13479                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13480                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13481                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
13482                'tf','hcl','proto','thrift','avsc','graphql','gql'
13483              ]);
13484              var codeFiles = [];
13485              for (var i = 0; i < files.length; i++) {
13486                var f = files[i];
13487                var name = f.name;
13488                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
13489                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
13490                  codeFiles.push(f); continue;
13491                }
13492                var dot = name.lastIndexOf('.');
13493                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
13494              }
13495              // Collect specific .git metadata files for server-side git detection.
13496              // These have no source extension so they are excluded by the loop above,
13497              // but the server needs them to read branch/commit/author without running git.
13498              var gitMetaFiles = [];
13499              for (var i = 0; i < files.length; i++) {
13500                var f = files[i];
13501                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
13502                var gitIdx = rp.indexOf('/.git/');
13503                if (gitIdx < 0) continue;
13504                var gitRel = rp.slice(gitIdx + 1);
13505                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
13506                    gitRel === '.git/logs/HEAD' ||
13507                    gitRel.startsWith('.git/refs/heads/') ||
13508                    gitRel.startsWith('.git/refs/tags/')) {
13509                  gitMetaFiles.push(f);
13510                }
13511              }
13512              var uploadFiles = codeFiles.concat(gitMetaFiles);
13513              var total = files.length;
13514              var kept = codeFiles.length;
13515              if (kept === 0) {
13516                if (previewPanel && targetInput === pathInput)
13517                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
13518                if (browseBtn) browseBtn.disabled = false;
13519                inputEl.value = '';
13520                return;
13521              }
13522
13523              // ── Helper: apply upload result to UI ────────────────────────
13524              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
13525              function applyUploadResult(tmpPath, sizes) {
13526                targetInput.value = tmpPath;
13527                scrollInputToEnd(targetInput);
13528                if (sizes && SERVER_MODE) {
13529                  window._lastUploadSizes = sizes;
13530                  // Immediately show both sizes before preview loads.
13531                  var sizeText = document.getElementById('project-size-text');
13532                  var sizeBtn = document.getElementById('project-size-btn');
13533                  if (sizeText) {
13534                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13535                      ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13536                  }
13537                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13538                    ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13539                }
13540                if (targetInput === pathInput) {
13541                  updateReportTitleFromPath();
13542                  autoSetOutputDir(tmpPath);
13543                  fetchProjectHistory(tmpPath);
13544                  loadPreview();
13545                  suggestCoverageFile(tmpPath);
13546                }
13547                updateReview();
13548                if (browseBtn) browseBtn.disabled = false;
13549                inputEl.value = '';
13550              }
13551
13552              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
13553              if (typeof CompressionStream !== 'undefined') {
13554                if (previewPanel && targetInput === pathInput)
13555                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13556
13557                // Build a minimal POSIX ustar tar header for a single file entry.
13558                function buildUstarHeader(filePath, fileSize) {
13559                  var BLOCK = 512;
13560                  var hdr = new Uint8Array(BLOCK);
13561                  var enc = new TextEncoder();
13562                  function wStr(off, len, s) {
13563                    var b = enc.encode(s);
13564                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
13565                  }
13566                  function wOct(off, len, val) {
13567                    var s = val.toString(8);
13568                    while (s.length < len - 1) s = '0' + s;
13569                    wStr(off, len, s + '\0');
13570                  }
13571                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
13572                  var name = filePath, prefix = '';
13573                  if (filePath.length > 99) {
13574                    var split = filePath.lastIndexOf('/', 154);
13575                    if (split > 0 && filePath.length - split - 1 <= 99) {
13576                      prefix = filePath.substring(0, split);
13577                      name   = filePath.substring(split + 1);
13578                    } else { name = filePath.substring(0, 99); }
13579                  }
13580                  wStr(0,   100, name);          // name
13581                  wOct(100,   8, 0o000644);      // mode
13582                  wOct(108,   8, 0);             // uid
13583                  wOct(116,   8, 0);             // gid
13584                  wOct(124,  12, fileSize);      // size
13585                  wOct(136,  12, 0);             // mtime (epoch)
13586                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
13587                  hdr[156] = 48;                 // type flag '0' = regular file
13588                  wStr(157, 100, '');            // linkname
13589                  wStr(257,   6, 'ustar');       // magic
13590                  wStr(263,   2, '00');          // version
13591                  wStr(265,  32, '');            // uname
13592                  wStr(297,  32, '');            // gname
13593                  wOct(329,   8, 0);             // devmajor
13594                  wOct(337,   8, 0);             // devminor
13595                  wStr(345, 155, prefix);        // prefix
13596                  // Compute checksum (sum of all bytes, placeholder = 32).
13597                  var chk = 0;
13598                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13599                  var cs = chk.toString(8);
13600                  while (cs.length < 6) cs = '0' + cs;
13601                  wStr(148, 8, cs + '\0 ');
13602                  return hdr;
13603                }
13604
13605                // Build tar.gz one file at a time, piping through CompressionStream.
13606                // RAM usage = compressed output buffer + one file at a time.
13607                (async function () {
13608                  try {
13609                    var BLOCK = 512;
13610                    var cs     = new CompressionStream('gzip');
13611                    var writer = cs.writable.getWriter();
13612                    var chunks = [];
13613                    var reader = cs.readable.getReader();
13614                    var collecting = (async function () {
13615                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
13616                    })();
13617
13618                    for (var i = 0; i < uploadFiles.length; i++) {
13619                      var file = uploadFiles[i];
13620                      var path = file.webkitRelativePath || file.name;
13621                      var buf  = await file.arrayBuffer();
13622                      var data = new Uint8Array(buf);
13623                      // Header block
13624                      await writer.write(buildUstarHeader(path, data.length));
13625                      // Data padded to 512-byte boundary
13626                      if (data.length > 0) {
13627                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
13628                        var block  = new Uint8Array(padded);
13629                        block.set(data);
13630                        await writer.write(block);
13631                      }
13632                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
13633                        if (previewPanel && targetInput === pathInput)
13634                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13635                      }
13636                    }
13637                    // End-of-archive: two 512-byte zero blocks
13638                    await writer.write(new Uint8Array(BLOCK * 2));
13639                    await writer.close();
13640                    await collecting;
13641
13642                    var blob = new Blob(chunks, { type: 'application/gzip' });
13643                    var sizeMB = (blob.size / 1048576).toFixed(1);
13644                    if (previewPanel && targetInput === pathInput)
13645                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
13646
13647                    var resp = await fetch('/api/upload-tarball', {
13648                      method: 'POST',
13649                      headers: { 'Content-Type': 'application/gzip' },
13650                      body: blob
13651                    });
13652                    var d = await resp.json();
13653                    if (d && d.tmp_path) {
13654                      applyUploadResult(d.tmp_path, {
13655                        compressed_bytes: d.compressed_bytes || 0,
13656                        original_bytes: d.original_bytes || 0
13657                      });
13658                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13659                  } catch (e) {
13660                    showBannerToast('Upload failed: ' + String(e), true);
13661                    if (browseBtn) browseBtn.disabled = false;
13662                    inputEl.value = '';
13663                  }
13664                })();
13665
13666              } else {
13667                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
13668                // Used only on browsers that lack CompressionStream (pre-2023).
13669                var BATCH = 200;
13670                var batches = [];
13671                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
13672                var totalBatches = batches.length;
13673                if (previewPanel && targetInput === pathInput)
13674                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
13675
13676                function sendBatch(idx, currentUploadId, lastTmpPath) {
13677                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
13678                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
13679                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
13680                  Promise.all(batches[idx].map(function (file) {
13681                    return fileToBase64(file).then(function (b64) {
13682                      return { path: file.webkitRelativePath || file.name, content: b64 };
13683                    });
13684                  })).then(function (fileList) {
13685                    var body = { files: fileList };
13686                    if (currentUploadId) body.upload_id = currentUploadId;
13687                    return fetch('/api/upload-directory', {
13688                      method: 'POST', headers: { 'Content-Type': 'application/json' },
13689                      body: JSON.stringify(body)
13690                    }).then(function (r) { return r.json(); });
13691                  }).then(function (d) {
13692                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
13693                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13694                  }).catch(function (e) {
13695                    showBannerToast('Upload failed: ' + String(e), true);
13696                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
13697                  });
13698                }
13699                sendBatch(0, null, '');
13700              }
13701            }
13702          };
13703          inputEl.click();
13704          return;
13705        }
13706
13707        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
13708        if (browseButton) browseButton.disabled = true;
13709
13710        if (previewPanel && targetInput === pathInput) {
13711          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
13712        }
13713
13714        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
13715          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
13716          .then(function (data) {
13717            if (data && data.selected_path) {
13718              targetInput.value = data.selected_path;
13719              scrollInputToEnd(targetInput);
13720
13721              if (targetInput === pathInput) {
13722                updateReportTitleFromPath();
13723                autoSetOutputDir(data.selected_path);
13724                fetchProjectHistory(data.selected_path);
13725                loadPreview();
13726                suggestCoverageFile(data.selected_path);
13727              }
13728
13729              updateReview();
13730            } else if (targetInput === pathInput) {
13731              loadPreview();
13732            }
13733          })
13734          .catch(function () {
13735            window.alert("Directory picker request failed.");
13736            if (previewPanel && targetInput === pathInput) {
13737              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
13738            }
13739          })
13740          .finally(function () {
13741            if (browseButton) browseButton.disabled = false;
13742          });
13743      }
13744
13745      if (themeToggle) {
13746        themeToggle.addEventListener("click", function () {
13747          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
13748          applyTheme(nextTheme);
13749          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
13750        });
13751      }
13752
13753      stepButtons.forEach(function (button) {
13754        button.addEventListener("click", function () {
13755          setStep(Number(button.getAttribute("data-step-target")));
13756        });
13757      });
13758
13759      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
13760        button.addEventListener("click", function () {
13761          setStep(Number(button.getAttribute("data-step-target")) || 1);
13762        });
13763      });
13764
13765      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
13766        button.addEventListener("click", function () {
13767          updateReview();
13768          setStep(Number(button.getAttribute("data-next")));
13769        });
13770      });
13771
13772      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
13773        button.addEventListener("click", function () {
13774          setStep(Number(button.getAttribute("data-prev")));
13775        });
13776      });
13777
13778      document.addEventListener("keydown", function (e) {
13779        var tag = (document.activeElement || {}).tagName || "";
13780        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
13781        if (e.altKey || e.ctrlKey || e.metaKey) return;
13782        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
13783        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
13784      });
13785
13786      if (useSamplePath) {
13787        useSamplePath.addEventListener("click", function () {
13788          pathInput.value = "tests/fixtures/basic";
13789          updateReportTitleFromPath();
13790          autoSetOutputDir("tests/fixtures/basic");
13791          loadPreview();
13792          suggestCoverageFile("tests/fixtures/basic");
13793        });
13794      }
13795
13796      if (useDefaultOutput) {
13797        useDefaultOutput.addEventListener("click", function () {
13798          delete outputDirInput.dataset.userEdited;
13799          autoSetOutputDir(pathInput ? pathInput.value : "");
13800          updateReview();
13801        });
13802      }
13803
13804      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
13805      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
13806
13807      // ── Drag-and-drop directory upload (server mode only) ─────────────────
13808      // Dropping a folder onto the path field bypasses Chrome's
13809      // "Upload X files to this site?" confirmation dialog.
13810      async function readDirRecursively(dirEntry, basePath) {
13811        var reader = dirEntry.createReader();
13812        var all = [];
13813        for (;;) {
13814          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
13815          if (!batch.length) break;
13816          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
13817        }
13818        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
13819        var out = [];
13820        for (var i = 0; i < all.length; i++) {
13821          var sub = all[i];
13822          if (sub.isFile) {
13823            var f = await new Promise(function(res) { sub.file(res); });
13824            out.push({ file: f, path: basePath + '/' + sub.name });
13825          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
13826            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
13827            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
13828          }
13829        }
13830        return out;
13831      }
13832
13833      function setupPathDropZone() {
13834        if (!SERVER_MODE || !pathInput) return;
13835        var CODE_EXTS = new Set([
13836          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13837          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13838          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13839          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13840          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13841          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
13842        ]);
13843        pathInput.addEventListener('dragover', function(e) {
13844          e.preventDefault();
13845          pathInput.classList.add('drag-over');
13846        });
13847        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
13848        pathInput.addEventListener('drop', function(e) {
13849          e.preventDefault();
13850          pathInput.classList.remove('drag-over');
13851          var items = e.dataTransfer.items;
13852          if (!items || !items.length) return;
13853          var dirEntry = null;
13854          for (var i = 0; i < items.length; i++) {
13855            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
13856            if (entry && entry.isDirectory) { dirEntry = entry; break; }
13857          }
13858          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
13859          var btn = browsePath;
13860          if (btn) btn.disabled = true;
13861          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
13862
13863          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
13864            var total = allEntries.length;
13865            var codeEntries = allEntries.filter(function(e) {
13866              var n = e.file.name;
13867              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
13868              var dot = n.lastIndexOf('.');
13869              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
13870            });
13871            var kept = codeEntries.length;
13872            if (kept === 0) {
13873              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
13874              if (btn) btn.disabled = false; return;
13875            }
13876
13877            function finish(tmpPath, sizes) {
13878              pathInput.value = tmpPath;
13879              scrollInputToEnd(pathInput);
13880              if (sizes) {
13881                window._lastUploadSizes = sizes;
13882                var sizeText = document.getElementById('project-size-text');
13883                var sizeBtn = document.getElementById('project-size-btn');
13884                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13885                  ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13886                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13887                  ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13888              }
13889              updateReportTitleFromPath();
13890              autoSetOutputDir(tmpPath);
13891              fetchProjectHistory(tmpPath);
13892              loadPreview();
13893              suggestCoverageFile(tmpPath);
13894              updateReview();
13895              if (btn) btn.disabled = false;
13896            }
13897
13898            if (typeof CompressionStream === 'undefined') {
13899              showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
13900              if (btn) btn.disabled = false; return;
13901            }
13902
13903            try {
13904              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13905              var BLOCK = 512;
13906              var cs = new CompressionStream('gzip');
13907              var wtr = cs.writable.getWriter();
13908              var chunks = [];
13909              var rdr = cs.readable.getReader();
13910              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
13911
13912              function buildHdr(fp, sz) {
13913                var hdr = new Uint8Array(BLOCK);
13914                var enc = new TextEncoder();
13915                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]; }
13916                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
13917                var nm = fp, pfx = '';
13918                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); } }
13919                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
13920                for (var i = 148; i < 156; i++) hdr[i] = 32;
13921                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);
13922                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13923                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
13924                return hdr;
13925              }
13926
13927              for (var i = 0; i < codeEntries.length; i++) {
13928                var ce = codeEntries[i];
13929                var buf = await ce.file.arrayBuffer();
13930                var data = new Uint8Array(buf);
13931                await wtr.write(buildHdr(ce.path, data.length));
13932                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
13933                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
13934                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13935              }
13936              await wtr.write(new Uint8Array(BLOCK * 2));
13937              await wtr.close();
13938              await collecting;
13939
13940              var blob = new Blob(chunks, { type: 'application/gzip' });
13941              var sizeMB = (blob.size / 1048576).toFixed(1);
13942              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
13943              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
13944              var d = await resp.json();
13945              if (d && d.tmp_path) {
13946                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
13947              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
13948            } catch (err) {
13949              showBannerToast('Upload failed: ' + String(err), true);
13950              if (btn) btn.disabled = false;
13951            }
13952          }).catch(function(err) {
13953            showBannerToast('Could not read folder: ' + String(err), true);
13954            if (btn) btn.disabled = false;
13955          });
13956        });
13957      }
13958      setupPathDropZone();
13959      if (browseCoverage) {
13960        browseCoverage.addEventListener("click", function () {
13961          pickDirectory(coverageInput || pathInput, "coverage");
13962        });
13963      }
13964
13965      function setCovStatus(state, opts) {
13966        if (!covScanStatus) return;
13967        opts = opts || {};
13968        covScanStatus.className = "cov-scan-status cov-scan-" + state;
13969        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
13970        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>';
13971        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>';
13972        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>';
13973        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>';
13974        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
13975        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
13976        if (state === "scanning") {
13977          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
13978        } else if (state === "found") {
13979          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13980          html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
13981          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
13982          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
13983        } else if (state === "hint") {
13984          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13985          html += '<div class="cov-scan-title">' + tb2 + ' detected &mdash; no coverage file found yet</div>';
13986          html += '<div class="cov-scan-sub">Generate one with:</div>';
13987          html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
13988        } else if (state === "none") {
13989          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
13990          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
13991        }
13992        html += '</div></div>';
13993        covScanStatus.innerHTML = html;
13994        if (state === "found") {
13995          var useBtn = covScanStatus.querySelector(".cov-scan-use");
13996          if (useBtn) useBtn.addEventListener("click", function () {
13997            if (coverageInput) coverageInput.value = "";
13998            covAutoFilled = false;
13999            setCovStatus("idle");
14000          });
14001        }
14002      }
14003
14004      function suggestCoverageFile(projectPath) {
14005        if (!coverageInput || !covScanStatus) return;
14006        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
14007        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
14008        clearTimeout(coverageSuggestTimer);
14009        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
14010        setCovStatus("scanning");
14011        coverageSuggestTimer = setTimeout(function () {
14012          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
14013            .then(function (r) { return r.json(); })
14014            .then(function (d) {
14015              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
14016              if (!d) { setCovStatus("none"); return; }
14017              if (d.found) {
14018                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
14019                setCovStatus("found", { found: d.found, tool: d.tool });
14020              } else if (d.tool && d.hint) {
14021                setCovStatus("hint", { tool: d.tool, hint: d.hint });
14022              } else {
14023                setCovStatus("none");
14024              }
14025            })
14026            .catch(function () { setCovStatus("idle"); });
14027        }, 600);
14028      }
14029
14030      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
14031
14032      if (coverageInput) coverageInput.addEventListener("input", function () {
14033        covAutoFilled = false;
14034        if (!this.value.trim()) setCovStatus("idle");
14035      });
14036
14037      // ── Language pill overflow: collapse to "+N more" chip ─────────────
14038      function collapseLanguagePills() {
14039        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
14040        rows.forEach(function(row) {
14041          // Remove any previous overflow chip
14042          var prev = row.querySelector('.lang-overflow-chip');
14043          if (prev) prev.remove();
14044          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
14045          pills.forEach(function(p) { p.style.display = ''; });
14046          if (!pills.length) return;
14047
14048          // Measure after restoring all pills
14049          var containerRight = row.getBoundingClientRect().right;
14050          var hidden = [];
14051          for (var i = pills.length - 1; i >= 1; i--) {
14052            var rect = pills[i].getBoundingClientRect();
14053            if (rect.right > containerRight + 2) {
14054              hidden.unshift(pills[i]);
14055              pills[i].style.display = 'none';
14056            } else {
14057              break;
14058            }
14059          }
14060
14061          if (hidden.length) {
14062            var chip = document.createElement('button');
14063            chip.type = 'button';
14064            chip.className = 'language-pill lang-overflow-chip';
14065            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
14066            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
14067            row.appendChild(chip);
14068          }
14069        });
14070      }
14071
14072      // Run after preview loads (preview panel populates language pills)
14073      var _origLoadPreviewCb = window.__previewLoaded;
14074      document.addEventListener('previewLoaded', collapseLanguagePills);
14075      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
14076      setTimeout(collapseLanguagePills, 400);
14077
14078      // ── Project history & output dir auto-set ──────────────────────────
14079      var wsOutputRoot   = document.getElementById("ws-output-root");
14080      var wsScanCount    = document.getElementById("ws-scan-count");
14081      var wsLastScan     = document.getElementById("ws-last-scan");
14082      var historyBadge   = document.getElementById("path-history-badge");
14083      var historyTimer   = null;
14084
14085      var wsOutputLink = document.getElementById("ws-output-link");
14086      function syncStripOutputRoot() {
14087        var val = outputDirInput ? outputDirInput.value : "";
14088        var display = val || "project/sloc";
14089        if (wsOutputRoot) wsOutputRoot.textContent = display;
14090        if (wsOutputLink) wsOutputLink.dataset.folder = val;
14091      }
14092
14093      function scrollInputToEnd(input) {
14094        if (!input) return;
14095        // Defer so the DOM has the new value before we measure scroll width.
14096        requestAnimationFrame(function () {
14097          input.scrollLeft = input.scrollWidth;
14098          input.selectionStart = input.selectionEnd = input.value.length;
14099        });
14100      }
14101
14102      function autoSetOutputDir(projectPath) {
14103        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
14104        if (GIT_MODE && GIT_OUTPUT_DIR) {
14105          outputDirInput.value = GIT_OUTPUT_DIR;
14106          scrollInputToEnd(outputDirInput);
14107          syncStripOutputRoot();
14108          updateReview();
14109          return;
14110        }
14111        if (!projectPath || !projectPath.trim()) return;
14112        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
14113        outputDirInput.value = cleaned + "/sloc";
14114        scrollInputToEnd(outputDirInput);
14115        syncStripOutputRoot();
14116        updateReview();
14117      }
14118
14119      var wsBranch = document.getElementById("ws-branch");
14120
14121      function fetchProjectHistory(projectPath) {
14122        if (!projectPath || !projectPath.trim()) {
14123          if (wsScanCount) wsScanCount.textContent = "—";
14124          if (wsLastScan)  wsLastScan.textContent  = "—";
14125          if (wsBranch)    wsBranch.textContent    = "—";
14126          if (historyBadge) historyBadge.style.display = "none";
14127          return;
14128        }
14129        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
14130          .then(function (r) { return r.ok ? r.json() : null; })
14131          .then(function (data) {
14132            if (!data) return;
14133            var countStr = data.scan_count > 0
14134              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
14135              : "never";
14136            var tsStr = data.last_scan_timestamp
14137              ? data.last_scan_timestamp.replace(" UTC","")
14138              : "—";
14139            if (wsScanCount) wsScanCount.textContent = countStr;
14140            if (wsLastScan)  wsLastScan.textContent  = tsStr;
14141            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
14142            if (data.scan_count > 0) {
14143              if (historyBadge) {
14144                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
14145                historyBadge.textContent = data.scan_count + " previous scan" +
14146                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
14147                  "Last: " + (data.last_scan_timestamp || "—") +
14148                  " — " + (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.";
14149                historyBadge.className = "path-history-badge found";
14150                historyBadge.style.display = "";
14151              }
14152            } else {
14153              if (historyBadge) historyBadge.style.display = "none";
14154            }
14155          })
14156          .catch(function () {});
14157      }
14158
14159      function onPathChange() {
14160        var val = pathInput ? pathInput.value : "";
14161        // Discard stale upload sizes when the user edits the path manually.
14162        window._lastUploadSizes = null;
14163        updateReportTitleFromPath();
14164        autoSetOutputDir(val);
14165        updateSidebarSummary();
14166        clearTimeout(historyTimer);
14167        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
14168        if (previewTimer) clearTimeout(previewTimer);
14169        previewTimer = setTimeout(loadPreview, 280);
14170        suggestCoverageFile(val);
14171      }
14172
14173      if (pathInput) {
14174        pathInput.addEventListener("input", onPathChange);
14175      }
14176
14177      if (outputDirInput) {
14178        outputDirInput.addEventListener("input", function () {
14179          outputDirInput.dataset.userEdited = "1";
14180          syncStripOutputRoot();
14181          updateReview();
14182        });
14183      }
14184
14185      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
14186        if (!node) return;
14187        node.addEventListener("input", function () {
14188          updateReview();
14189          if (previewTimer) clearTimeout(previewTimer);
14190          previewTimer = setTimeout(loadPreview, 280);
14191        });
14192      });
14193
14194      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
14195        var node = document.getElementById(id);
14196        if (node) node.addEventListener("change", updateReview);
14197      });
14198
14199      if (reportTitleInput) {
14200        reportTitleInput.addEventListener("input", function () {
14201          reportTitleTouched = reportTitleInput.value.trim().length > 0;
14202          updateReportTitleFromPath();
14203          updateReview();
14204        });
14205      }
14206
14207      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
14208      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
14209      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
14210      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
14211
14212      artifactCards.forEach(function (card) {
14213        card.addEventListener("click", function () {
14214          if (card.classList.contains("artifact-locked")) return;
14215          toggleArtifactCard(card);
14216          updateReview();
14217        });
14218      });
14219
14220      if (coverageInput) {
14221        coverageInput.addEventListener("input", function () {
14222          if (coverageInput.value.trim()) setCovStatus("idle");
14223        });
14224      }
14225
14226      if (form && loading && submitButton) {
14227        form.addEventListener("submit", function (e) {
14228          e.preventDefault();
14229          submitButton.disabled = true;
14230          submitButton.textContent = "Scanning...";
14231          startAsyncAnalysis(new FormData(form));
14232        });
14233      }
14234
14235      function openPath(folder) {
14236        if (!folder) return;
14237        fetch('/open-path?path=' + encodeURIComponent(folder))
14238          .then(function (r) { return r.json(); })
14239          .then(function (d) {
14240            if (d && d.server_mode_disabled)
14241              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
14242          })
14243          .catch(function () {});
14244      }
14245
14246      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14247        btn.addEventListener('click', function () {
14248          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
14249        });
14250      });
14251
14252      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
14253      if (wsOutputLink) {
14254        wsOutputLink.addEventListener('click', function () {
14255          openPath(wsOutputLink.dataset.folder || '');
14256        });
14257      }
14258
14259      loadSavedTheme();
14260      updateMixedPolicyUI();
14261      updatePythonDocstringUI();
14262      applyScanPreset();
14263      updatePresetDescriptions();
14264      applyArtifactPreset();
14265      updateReview();
14266      updateScrollProgress(); // initialise bar to 0% (step 1)
14267      window.addEventListener("scroll", updateScrollProgress, { passive: true });
14268      onPathChange();         // seed output dir, history badge, and preview from initial path
14269      loadPreview();
14270      updateStepNav(1);
14271
14272      // Restore step from URL hash on initial load (e.g., back-forward cache)
14273      (function() {
14274        var hashMatch = location.hash.match(/^#step([1-4])$/);
14275        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
14276      })();
14277
14278      (function randomizeWatermarks() {
14279        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14280        if (!wms.length) return;
14281        var placed = [];
14282        function tooClose(top, left) {
14283          for (var i = 0; i < placed.length; i++) {
14284            var dt = Math.abs(placed[i][0] - top);
14285            var dl = Math.abs(placed[i][1] - left);
14286            if (dt < 16 && dl < 12) return true;
14287          }
14288          return false;
14289        }
14290        function pick(leftBand) {
14291          for (var attempt = 0; attempt < 50; attempt++) {
14292            var top = Math.random() * 88 + 2;
14293            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14294            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14295          }
14296          var top = Math.random() * 88 + 2;
14297          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14298          placed.push([top, left]);
14299          return [top, left];
14300        }
14301        var half = Math.floor(wms.length / 2);
14302        wms.forEach(function (img, i) {
14303          var pos = pick(i < half);
14304          var size = Math.floor(Math.random() * 80 + 110);
14305          var rot = (Math.random() * 360).toFixed(1);
14306          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
14307          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;
14308        });
14309      })();
14310
14311      (function spawnCodeParticles() {
14312        var container = document.getElementById('code-particles');
14313        if (!container) return;
14314        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'];
14315        for (var i = 0; i < 38; i++) {
14316          (function(idx) {
14317            var el = document.createElement('span');
14318            el.className = 'code-particle';
14319            el.textContent = snippets[idx % snippets.length];
14320            var left = Math.random() * 94 + 2;
14321            var top = Math.random() * 88 + 6;
14322            var dur = (Math.random() * 10 + 9).toFixed(1);
14323            var delay = (Math.random() * 18).toFixed(1);
14324            var rot = (Math.random() * 26 - 13).toFixed(1);
14325            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14326            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';
14327            container.appendChild(el);
14328          })(i);
14329        }
14330      })();
14331    })();
14332  </script>
14333  <script nonce="{{ csp_nonce }}">
14334    (function () {
14335      var raw = {{ prefill_json|safe }};
14336      if (!raw || typeof raw !== 'object' || !raw.path) return;
14337      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output-dir') scrollInputToEnd(el); } }
14338      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
14339      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
14340      setVal('path-input', raw.path || '');
14341      setVal('include-globs', raw.include_globs || '');
14342      setVal('exclude-globs', raw.exclude_globs || '');
14343      setVal('output-dir', raw.output_dir || '');
14344      setVal('report-title', raw.report_title || '');
14345      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
14346      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
14347      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
14348      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
14349      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
14350      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
14351      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
14352      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
14353      setChecked('generate-html', raw.generate_html !== false);
14354      setChecked('generate-pdf', !!raw.generate_pdf);
14355      // Trigger dynamic UI updates after pre-fill.
14356      setTimeout(function () {
14357        var pathEl = document.getElementById('path-input');
14358        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
14359        var policyEl = document.getElementById('mixed-line-policy');
14360        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
14361      }, 80);
14362    })();
14363  </script>
14364  <script nonce="{{ csp_nonce }}">
14365  (function(){
14366    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'}];
14367    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);});}
14368    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14369    function init(){
14370      var btn=document.getElementById('settings-btn');if(!btn)return;
14371      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14372      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>';
14373      document.body.appendChild(m);
14374      var g=document.getElementById('scheme-grid');
14375      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);});
14376      var cl=document.getElementById('settings-close');
14377      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);
14378      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');});
14379      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14380      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14381    }
14382    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14383  }());
14384  </script>
14385  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
14386    <div class="wb-ftip-arrow"></div>
14387    <span id="wb-ftip-text"></span>
14388  </div>
14389  <script nonce="{{ csp_nonce }}">(function(){
14390    var tip=document.getElementById('wb-ftip');
14391    var txt=document.getElementById('wb-ftip-text');
14392    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
14393    if(!tip||!txt)return;
14394    function pos(el){
14395      var r=el.getBoundingClientRect();
14396      tip.style.display='block';
14397      var tw=tip.offsetWidth;
14398      var lx=r.left+r.width/2-tw/2;
14399      if(lx<8)lx=8;
14400      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
14401      tip.style.left=lx+'px';
14402      tip.style.top=(r.bottom+8)+'px';
14403      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';}
14404    }
14405    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
14406      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
14407      el.addEventListener('mouseleave',function(){tip.style.display='none';});
14408    });
14409  })();
14410  (function(){
14411    function fixArtifactHintSpacing(){
14412      var grid=document.querySelector('.artifact-grid');
14413      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
14414    }
14415    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
14416  }());
14417  (function(){
14418    var dot=document.getElementById('status-dot');
14419    var pingEl=document.getElementById('server-ping-ms');
14420    var tipEl=document.getElementById('server-tip-ping');
14421    var fm=document.getElementById('footer-mode');
14422    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)';}}
14423    function doPing(){
14424      var t0=performance.now();
14425      fetch('/healthz',{cache:'no-store'})
14426        .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);})
14427        .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)';}});
14428    }
14429    doPing();
14430    setInterval(doPing,5000);
14431    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');}
14432  })();
14433  </script>
14434  <footer class="site-footer">
14435    local code analysis - metrics, history and reports
14436    &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>
14437    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14438    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14439    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14440    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14441  </footer>
14442</body>
14443</html>
14444"##,
14445    ext = "html"
14446)]
14447struct IndexTemplate {
14448    version: &'static str,
14449    prefill_json: String,
14450    csp_nonce: String,
14451    git_repo: String,
14452    git_ref: String,
14453    git_label_json: String,
14454    git_output_dir_json: String,
14455    server_mode: bool,
14456}
14457
14458// ── SplashTemplate ────────────────────────────────────────────────────────────
14459
14460#[derive(Template)]
14461#[template(
14462    source = r##"
14463<!doctype html>
14464<html lang="en">
14465<head>
14466  <meta charset="utf-8">
14467  <meta name="viewport" content="width=device-width, initial-scale=1">
14468  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
14469  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14470  <style nonce="{{ csp_nonce }}">
14471    :root {
14472      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14473      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14474      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14475      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14476      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14477    }
14478    body.dark-theme {
14479      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14480      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14481    }
14482    *{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;}
14483    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14484    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14485    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14486    .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;}
14487    @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));}}
14488    .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);}
14489    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14490    .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));}
14491    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14492    .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;}
14493    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14494    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14495    @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; } }
14496    .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;}
14497    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14498    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14499    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14500    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14501    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14502    .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;}
14503    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14504    .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);}
14505    .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;}
14506    .settings-close:hover{color:var(--text);background:var(--surface-2);}
14507    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14508    .settings-modal-body{padding:14px 16px 16px;}
14509    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14510    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14511    .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;}
14512    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14513    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14514    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14515    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14516    .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;}
14517    .tz-select:focus{border-color:var(--oxide);}
14518    .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;}
14519    .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;}
14520    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
14521    .hero{text-align:center;margin:0 auto 18px;}
14522    .hero-logo-wrap{display:inline-block;cursor:default;}
14523    .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;}
14524    .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;}
14525    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
14526    .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;}
14527    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%);}
14528    .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;
14529      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
14530      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
14531      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;}
14532    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
14533    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
14534    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;}
14535    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
14536    .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;}
14537    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
14538    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
14539    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
14540    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
14541    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
14542    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
14543    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
14544    .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;}
14545    .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;}
14546    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14547    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14548    .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);}
14549    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
14550    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
14551    .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);}
14552    .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);}
14553    .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);}
14554    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
14555    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
14556    .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;}
14557    body.dark-theme .action-card-cta{color:var(--oxide);}
14558    .action-card.view .action-card-cta{color:var(--accent-2);}
14559    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
14560    .action-card.compare .action-card-cta{color:#7c3aed;}
14561    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
14562    .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);}
14563    .action-card.git-tools .action-card-cta{color:#15803d;}
14564    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
14565    .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);}
14566    .action-card.trend .action-card-cta{color:#0e7490;}
14567    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
14568    .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);}
14569    .action-card.automation .action-card-cta{color:#b45309;}
14570    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
14571    .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);}
14572    .action-card.test-metrics .action-card-cta{color:#be185d;}
14573    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
14574    .action-card:hover .action-card-cta{gap:12px;}
14575    .action-card.card-split{flex-direction:row;align-items:stretch;}
14576    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
14577    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
14578    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
14579    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
14580    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
14581    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
14582    .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;}
14583    .ac-badge.active{opacity:1;}
14584    .ac-badge.github{border-color:#555;color:#555;}
14585    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
14586    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
14587    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
14588    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
14589    body.dark-theme .ac-right-row{color:var(--muted);}
14590    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
14591    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
14592    .divider{height:1px;background:var(--line);margin:32px 0;}
14593    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
14594    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
14595    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
14596    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
14597      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
14598    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14599    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
14600    body.dark-theme .info-chip-val{color:var(--oxide);}
14601    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
14602    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
14603      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
14604      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
14605    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
14606      border:6px solid transparent;border-top-color:var(--text);}
14607    .info-chip:hover .info-chip-tip{display:block;}
14608    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
14609    .chip-slide.fading{filter:blur(5px);opacity:0;}
14610    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14611    .site-footer a{color:var(--muted);}
14612    .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;}
14613    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
14614    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
14615    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
14616    .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;}
14617    .lan-badge.local{background:var(--oxide-2);}
14618    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
14619    .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);}
14620    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
14621    .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;}
14622    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
14623    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
14624    .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;}
14625    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
14626    .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;}
14627    .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);}
14628    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
14629    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
14630    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
14631    .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;}
14632    @media (max-height: 1100px) {
14633      .page{padding-top:10px;}
14634      .hero{margin-bottom:10px;}
14635      .hero-logo{width:54px;height:60px;}
14636      .hero-logo-shadow{width:42px;}
14637      .hero-title{font-size:28px;}
14638      .hero-subtitle{font-size:13px;}
14639      .card-sections{gap:16px;margin-bottom:10px;}
14640      .card-section-grid-2,.card-section-grid-3{gap:10px;}
14641      .action-card{padding:8px 15px 8px;}
14642      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
14643      .action-card-icon svg{width:18px;height:18px;}
14644      .action-card-title{font-size:13px;}
14645      .action-card-desc{font-size:11px;margin-bottom:6px;}
14646      .action-card-cta{font-size:11px;}
14647      .ac-right-row{font-size:11px;}
14648      .divider{margin:14px 0;}
14649      .info-strip{gap:7px;margin-bottom:12px;}
14650      .info-chip{padding:7px 10px;}
14651      .info-chip-val{font-size:13px;}
14652      .info-chip-label{font-size:9px;}
14653      .site-footer{padding:8px 24px;font-size:12px;}
14654    }
14655    @media (max-height: 850px) {
14656      .page{padding-top:6px;}
14657      .hero{margin-bottom:6px;}
14658      .hero-logo{width:42px;height:46px;}
14659      .hero-title{font-size:22px;}
14660      .hero-subtitle{font-size:12px;}
14661      .card-sections{gap:10px;}
14662      .action-card-desc{margin-bottom:4px;}
14663      .divider{margin:8px 0;}
14664      .info-strip{margin-bottom:6px;}
14665      .lan-local-hint{margin-top:10px;}
14666    }
14667  </style>
14668</head>
14669<body>
14670  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14678  </div>
14679  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14680  <div class="top-nav">
14681    <div class="top-nav-inner">
14682      <a class="brand" href="/">
14683        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14684        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14685      </a>
14686      <div class="nav-right">
14687        <a class="nav-pill" href="/">Home</a>
14688        <div class="nav-dropdown">
14689          <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>
14690          <div class="nav-dropdown-menu">
14691            <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>
14692          </div>
14693        </div>
14694        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14695        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14696        <div class="nav-dropdown">
14697          <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>
14698          <div class="nav-dropdown-menu">
14699            <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>
14700          </div>
14701        </div>
14702        <div class="server-status-wrap" id="server-status-wrap">
14703          <div class="nav-pill server-online-pill" id="server-status-pill">
14704            <span class="status-dot" id="status-dot"></span>
14705            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
14706            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14707          </div>
14708          <div class="server-status-tip">
14709            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
14710            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14711          </div>
14712        </div>
14713        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14714          <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>
14715        </button>
14716        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14717          <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>
14718          <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>
14719        </button>
14720      </div>
14721    </div>
14722  </div>
14723
14724  <div class="page">
14725    <div class="hero">
14726      <div class="hero-logo-wrap" id="hero-logo-wrap">
14727        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
14728      </div>
14729      <div class="hero-logo-shadow"></div>
14730      <div class="hero-title-wrap">
14731        <div class="hero-title-aura" aria-hidden="true"></div>
14732        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
14733      </div>
14734      <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>
14735    </div>
14736
14737    <div class="card-sections">
14738
14739      <div>
14740        <div class="card-section-label">Analysis</div>
14741        <div class="card-section-grid-2">
14742          <a class="action-card scan card-split" href="/scan-setup">
14743            <div class="action-card-left">
14744              <div class="action-card-icon">
14745                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
14746              </div>
14747              <div class="action-card-title">Scan Project</div>
14748              <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>
14749              <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>
14750            </div>
14751            <div class="action-card-sep"></div>
14752            <div class="action-card-right">
14753              <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>
14754              <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>
14755              <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>
14756              <div class="ac-right-stat" id="acp-scan-stat"></div>
14757            </div>
14758          </a>
14759          <a class="action-card test-metrics card-split" href="/test-metrics">
14760            <div class="action-card-left">
14761              <div class="action-card-icon">
14762                <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>
14763              </div>
14764              <div class="action-card-title">Test Metrics</div>
14765              <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>
14766              <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>
14767            </div>
14768            <div class="action-card-sep"></div>
14769            <div class="action-card-right">
14770              <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>
14771              <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>
14772              <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>
14773              <div class="ac-right-stat" id="acp-test-stat"></div>
14774            </div>
14775          </a>
14776        </div>
14777      </div>
14778
14779      <div>
14780        <div class="card-section-label">Reports &amp; Insights</div>
14781        <div class="card-section-grid-3">
14782          <a class="action-card view" href="/view-reports">
14783            <div class="action-card-icon">
14784              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
14785            </div>
14786            <div class="action-card-title">View Reports</div>
14787            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
14788            <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>
14789          </a>
14790          <a class="action-card compare" href="/compare-scans">
14791            <div class="action-card-icon">
14792              <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>
14793            </div>
14794            <div class="action-card-title">Compare Scans</div>
14795            <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>
14796            <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>
14797          </a>
14798          <a class="action-card trend" href="/trend-reports">
14799            <div class="action-card-icon">
14800              <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>
14801            </div>
14802            <div class="action-card-title">Trend Report</div>
14803            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
14804            <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>
14805          </a>
14806        </div>
14807      </div>
14808
14809      <div>
14810        <div class="card-section-label">Developer Tools</div>
14811        <div class="card-section-grid-2">
14812          <a class="action-card git-tools card-split" href="/git-browser">
14813            <div class="action-card-left">
14814              <div class="action-card-icon">
14815                <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>
14816              </div>
14817              <div class="action-card-title">Git Browser</div>
14818              <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>
14819              <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>
14820            </div>
14821            <div class="action-card-sep"></div>
14822            <div class="action-card-right">
14823              <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>
14824              <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>
14825              <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>
14826            </div>
14827          </a>
14828          <a class="action-card automation card-split" href="/integrations">
14829            <div class="action-card-left">
14830              <div class="action-card-icon">
14831                <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>
14832              </div>
14833              <div class="action-card-title">Integrations</div>
14834              <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>
14835              <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>
14836            </div>
14837            <div class="action-card-sep"></div>
14838            <div class="action-card-right">
14839              <div class="ac-badges-grid">
14840                <span class="ac-badge github"     id="acp-gh">GitHub</span>
14841                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
14842                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
14843                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
14844              </div>
14845              <div class="ac-right-stat" id="acp-int-stat"></div>
14846            </div>
14847          </a>
14848        </div>
14849      </div>
14850
14851    </div>
14852
14853    {% if server_mode %}
14854    <div class="lan-card server">
14855      <div class="lan-card-header">
14856        <span class="lan-badge">LAN server</span>
14857        Accessible on your network
14858      </div>
14859      {% if let Some(ip) = lan_ip %}
14860      <div class="lan-url-row">
14861        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
14862        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
14863          <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>
14864          Copy URL
14865        </button>
14866      </div>
14867      <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>
14868      {% if has_api_key %}
14869      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
14870      {% endif %}
14871      {% else %}
14872      <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>
14873      {% endif %}
14874    </div>
14875    {% endif %}
14876
14877    <div class="divider"></div>
14878
14879    <div class="info-strip">
14880      <div class="info-chip">
14881        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
14882        <div class="chip-slide">
14883          <div class="info-chip-val">41</div>
14884          <div class="info-chip-label">Languages</div>
14885        </div>
14886      </div>
14887      <div class="info-chip">
14888        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
14889        <div class="chip-slide">
14890          <div class="info-chip-val">100%</div>
14891          <div class="info-chip-label">Self-contained</div>
14892        </div>
14893      </div>
14894      <div class="info-chip">
14895        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
14896        <div class="chip-slide">
14897          <div class="info-chip-val">HTML+PDF</div>
14898          <div class="info-chip-label">Exportable reports</div>
14899        </div>
14900      </div>
14901      <div class="info-chip">
14902        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
14903        <div class="chip-slide">
14904          <div class="info-chip-val">Webhook</div>
14905          <div class="info-chip-label">3 platforms</div>
14906        </div>
14907      </div>
14908      <div class="info-chip">
14909        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
14910        <div class="chip-slide">
14911          <div class="info-chip-val">IEEE</div>
14912          <div class="info-chip-label">1045-1992</div>
14913        </div>
14914      </div>
14915    </div>
14916
14917    {% if lan_ip.is_none() %}
14918    <div class="lan-local-hint">
14919      <strong>Want teammates on the same network to access this?</strong><br>
14920      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
14921    </div>
14922    {% endif %}
14923  </div>
14924
14925  <footer class="site-footer">
14926    local code analysis - metrics, history and reports
14927    &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>
14928    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14929    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14930    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14931    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14932  </footer>
14933
14934  <script nonce="{{ csp_nonce }}">
14935    (function () {
14936      var storageKey = 'oxide-sloc-theme';
14937      var body = document.body;
14938      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
14939      var toggle = document.getElementById('theme-toggle');
14940      if (toggle) toggle.addEventListener('click', function () {
14941        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
14942        body.classList.toggle('dark-theme', next === 'dark');
14943        try { localStorage.setItem(storageKey, next); } catch(e) {}
14944      });
14945      var copyBtn = document.getElementById('lan-copy-btn');
14946      if (copyBtn) copyBtn.addEventListener('click', function() {
14947        var btn = this;
14948        var el = document.getElementById('lan-url-val');
14949        if (!el) return;
14950        var url = el.textContent.trim();
14951        if (navigator.clipboard) {
14952          navigator.clipboard.writeText(url).then(function() {
14953            var orig = btn.innerHTML;
14954            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!';
14955            setTimeout(function() { btn.innerHTML = orig; }, 1800);
14956          });
14957        }
14958      });
14959      (function randomizeWatermarks() {
14960        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14961        if (!wms.length) return;
14962        var placed = [];
14963        function tooClose(top, left) {
14964          for (var i = 0; i < placed.length; i++) {
14965            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
14966            if (dt < 16 && dl < 12) return true;
14967          }
14968          return false;
14969        }
14970        function pick(leftBand) {
14971          for (var attempt = 0; attempt < 50; attempt++) {
14972            var top = Math.random() * 88 + 2;
14973            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14974            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14975          }
14976          var top = Math.random() * 88 + 2;
14977          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14978          placed.push([top, left]); return [top, left];
14979        }
14980        var half = Math.floor(wms.length / 2);
14981        wms.forEach(function (img, i) {
14982          var pos = pick(i < half);
14983          var size = Math.floor(Math.random() * 100 + 120);
14984          var rot = (Math.random() * 360).toFixed(1);
14985          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
14986          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;
14987        });
14988      })();
14989
14990      (function spawnCodeParticles() {
14991        var container = document.getElementById('code-particles');
14992        if (!container) return;
14993        var snippets = [
14994          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
14995          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
14996          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
14997          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
14998          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
14999        ];
15000        var count = 38;
15001        for (var i = 0; i < count; i++) {
15002          (function(idx) {
15003            var el = document.createElement('span');
15004            el.className = 'code-particle';
15005            var text = snippets[idx % snippets.length];
15006            el.textContent = text;
15007            var left = Math.random() * 94 + 2;
15008            var top = Math.random() * 88 + 6;
15009            var dur = (Math.random() * 10 + 9).toFixed(1);
15010            var delay = (Math.random() * 18).toFixed(1);
15011            var rot = (Math.random() * 26 - 13).toFixed(1);
15012            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15013            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
15014              + '--rot:' + rot + 'deg;--op:' + op + ';'
15015              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
15016            container.appendChild(el);
15017          })(i);
15018        }
15019      })();
15020      (function heroAnimations() {
15021        var sub = document.getElementById('hero-subtitle');
15022        if (sub) {
15023          var full = sub.textContent.trim();
15024          sub.textContent = '';
15025          sub.style.opacity = '1';
15026          var cursor = document.createElement('span');
15027          cursor.className = 'hero-cursor';
15028          sub.appendChild(cursor);
15029          var i = 0;
15030          setTimeout(function() {
15031            var iv = setInterval(function() {
15032              if (i < full.length) {
15033                sub.insertBefore(document.createTextNode(full[i]), cursor);
15034                i++;
15035              } else {
15036                clearInterval(iv);
15037                setTimeout(function() {
15038                  cursor.style.transition = 'opacity 1s ease';
15039                  cursor.style.opacity = '0';
15040                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
15041                }, 2400);
15042              }
15043            }, 11);
15044          }, 374);
15045        }
15046      })();
15047      (function logoBob() {
15048        var logo = document.querySelector('.hero-logo');
15049        var shadow = document.querySelector('.hero-logo-shadow');
15050        if (!logo) return;
15051        var cycleStart = null, cycleDur = 3600;
15052        var peakY = -14, peakScale = 1.07, peakRot = 0;
15053        function newCycle() {
15054          cycleDur = 3000 + Math.random() * 1840;
15055          peakY = -(9 + Math.random() * 13.8);
15056          peakScale = 1.04 + Math.random() * 0.081;
15057          peakRot = (Math.random() * 11.5 - 5.75);
15058        }
15059        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
15060        newCycle();
15061        function frame(ts) {
15062          if (cycleStart === null) cycleStart = ts;
15063          var t = (ts - cycleStart) / cycleDur;
15064          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
15065          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
15066          var y = peakY * phase;
15067          var sc = 1 + (peakScale - 1) * phase;
15068          var rot = peakRot * Math.sin(Math.PI * phase);
15069          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
15070          if (shadow) {
15071            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
15072            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
15073          }
15074          requestAnimationFrame(frame);
15075        }
15076        requestAnimationFrame(frame);
15077      })();
15078      (function mouseEffects() {
15079        var heroTitle = document.getElementById('hero-title');
15080        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
15081        function tick() {
15082          raf = null;
15083          if (heroTitle) {
15084            var r = heroTitle.getBoundingClientRect();
15085            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
15086            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
15087            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
15088          }
15089        }
15090        document.addEventListener('mousemove', function(e) {
15091          mx = e.clientX; my = e.clientY;
15092          if (!raf) raf = requestAnimationFrame(tick);
15093        });
15094        document.addEventListener('mouseleave', function() {
15095          if (heroTitle) {
15096            heroTitle.style.transition = 'transform 0.5s ease';
15097            heroTitle.style.transform = '';
15098            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
15099          }
15100        });
15101        document.querySelectorAll('.action-card').forEach(function(card) {
15102          card.addEventListener('mousemove', function(e) {
15103            var rect = card.getBoundingClientRect();
15104            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
15105            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
15106            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
15107            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
15108          });
15109          card.addEventListener('mouseleave', function() {
15110            card.style.transition = '';
15111            card.style.transform = '';
15112          });
15113        });
15114      })();
15115      (function chipSlideshow() {
15116        var slides = [
15117          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
15118          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
15119          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
15120          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
15121          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
15122        ];
15123        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
15124        var indices = [0,0,0,0,0];
15125        var paused = [false,false,false,false,false];
15126        chips.forEach(function(chip, i) {
15127          chip.addEventListener('mouseenter', function() { paused[i] = true; });
15128          chip.addEventListener('mouseleave', function() { paused[i] = false; });
15129        });
15130        function advance(i) {
15131          if (paused[i]) return;
15132          var chip = chips[i];
15133          var inner = chip.querySelector('.chip-slide');
15134          if (!inner) return;
15135          inner.classList.add('fading');
15136          setTimeout(function() {
15137            indices[i] = (indices[i] + 1) % slides[i].length;
15138            var s = slides[i][indices[i]];
15139            chip.querySelector('.info-chip-val').textContent = s.v;
15140            chip.querySelector('.info-chip-label').textContent = s.l;
15141            inner.classList.remove('fading');
15142          }, 720);
15143        }
15144        setInterval(function() {
15145          chips.forEach(function(chip, i) { advance(i); });
15146        }, 6000);
15147      })();
15148      (function cardLiveData() {
15149        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
15150          var el = document.getElementById('acp-scan-stat');
15151          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
15152        }).catch(function(){});
15153        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
15154          var el = document.getElementById('acp-test-stat');
15155          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
15156        }).catch(function(){});
15157        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
15158          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
15159          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
15160          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
15161          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
15162          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
15163          var stat = document.getElementById('acp-int-stat');
15164          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
15165        }).catch(function(){});
15166        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
15167          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
15168        }).catch(function(){});
15169      })();
15170    })();
15171  </script>
15172  <script nonce="{{ csp_nonce }}">
15173  (function(){
15174    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'}];
15175    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);});}
15176    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15177    function init(){
15178      var btn=document.getElementById('settings-btn');if(!btn)return;
15179      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15180      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>';
15181      document.body.appendChild(m);
15182      var g=document.getElementById('scheme-grid');
15183      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);});
15184      var cl=document.getElementById('settings-close');
15185      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);
15186      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');});
15187      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15188      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15189    }
15190    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15191  }());
15192  </script>
15193  <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>
15194</body>
15195</html>
15196"##,
15197    ext = "html"
15198)]
15199struct SplashTemplate {
15200    csp_nonce: String,
15201    server_mode: bool,
15202    lan_ip: Option<String>,
15203    port: u16,
15204    version: &'static str,
15205    has_api_key: bool,
15206}
15207
15208// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
15209
15210#[derive(Template)]
15211#[template(
15212    source = r##"
15213<!doctype html>
15214<html lang="en">
15215<head>
15216  <meta charset="utf-8">
15217  <meta name="viewport" content="width=device-width, initial-scale=1">
15218  <title>OxideSLOC — Start a Scan</title>
15219  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15220  <style nonce="{{ csp_nonce }}">
15221    :root {
15222      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
15223      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15224      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15225      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15226      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
15227    }
15228    body.dark-theme {
15229      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
15230      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
15231    }
15232    *{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;}
15233    .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);}
15234    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15235    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
15236    .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));}
15237    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
15238    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
15239    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
15240    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15241    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15242    @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; } }
15243    .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;}
15244    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15245    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
15246    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15247    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15248    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15249    .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;}
15250    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15251    .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);}
15252    .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;}
15253    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15254    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15255    .settings-modal-body{padding:14px 16px 16px;}
15256    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15257    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15258    .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;}
15259    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15260    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15261    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15262    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15263    .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;}
15264    .tz-select:focus{border-color:var(--oxide);}
15265    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
15266    .page-header{text-align:center;margin-bottom:16px;}
15267    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
15268    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
15269    /* Cards */
15270    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
15271    .option-card-wrap{position:relative;}
15272    .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;}
15273    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
15274    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
15275    .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;}
15276    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
15277    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
15278    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
15279    .card-top-row{display:flex;align-items:center;gap:20px;}
15280    /* Two-column layout inside each card */
15281    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
15282    .card-left{display:flex;align-items:flex-start;min-width:0;}
15283    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
15284    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
15285    .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);}
15286    .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);}
15287    .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);}
15288    .card-text{min-width:0;}
15289    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
15290    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
15291    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
15292    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
15293    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
15294    /* Right CTA column */
15295    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
15296    .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;}
15297    /* Re-scan count badge */
15298    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
15299    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
15300    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
15301    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
15302    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
15303    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
15304    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
15305    body.dark-theme .btn-secondary{color:var(--oxide);}
15306    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
15307    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
15308    /* File input overlay — must be full-width so it aligns with other card-right buttons */
15309    .file-input-wrap{position:relative;width:100%;}
15310    .file-input-wrap .btn{width:100%;}
15311    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
15312    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15313    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15314    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15315    .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;}
15316    @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));}}
15317    /* Recent list (card 3 — full-width section below header) */
15318    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
15319    .recent-list{display:flex;flex-direction:column;gap:8px;}
15320    .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;}
15321    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
15322    .recent-item-info{flex:1;min-width:0;}
15323    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
15324    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
15325    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
15326    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
15327    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15328    .site-footer a{color:var(--muted);}
15329    @media(max-width:680px){
15330      .card-body{grid-template-columns:1fr;}
15331      .card-right{flex-direction:row;flex-wrap:wrap;}
15332      .btn{flex:1;}
15333    }
15334    .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;}
15335    .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;}
15336    .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;}
15337  </style>
15338</head>
15339<body>
15340  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15348  </div>
15349  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15350  <div class="top-nav">
15351    <div class="top-nav-inner">
15352      <a class="brand" href="/">
15353        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15354        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15355      </a>
15356      <div class="nav-right">
15357        <a class="nav-pill" href="/">Home</a>
15358        <div class="nav-dropdown">
15359          <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>
15360          <div class="nav-dropdown-menu">
15361            <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>
15362          </div>
15363        </div>
15364        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15365        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15366        <div class="nav-dropdown">
15367          <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>
15368          <div class="nav-dropdown-menu">
15369            <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>
15370          </div>
15371        </div>
15372        <div class="server-status-wrap" id="server-status-wrap">
15373          <div class="nav-pill server-online-pill" id="server-status-pill">
15374            <span class="status-dot" id="status-dot"></span>
15375            <span id="server-status-label">Server</span>
15376            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15377          </div>
15378          <div class="server-status-tip">
15379            OxideSLOC is running — accessible on your network.
15380            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15381          </div>
15382        </div>
15383        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15384          <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>
15385        </button>
15386        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15387          <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>
15388          <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>
15389        </button>
15390      </div>
15391    </div>
15392  </div>
15393
15394  <div class="page">
15395    <div class="page-header">
15396      <h1>How would you like to scan?</h1>
15397      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
15398    </div>
15399
15400    <div class="option-grid">
15401
15402      <!-- Option 1: New scan -->
15403      <div class="option-card-wrap">
15404        <div class="option-card">
15405        <div class="option-icon new-scan">
15406          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
15407        </div>
15408        <div class="card-body">
15409          <div class="card-left">
15410            <div class="card-text">
15411              <div class="option-title">Start a new scan</div>
15412              <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>
15413              <ul class="feature-list">
15414                <li>Live project scope preview before you run</li>
15415                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
15416                <li>HTML, PDF, and JSON output — your choice</li>
15417              </ul>
15418            </div>
15419          </div>
15420          <div class="card-right">
15421            <a class="btn btn-primary" href="/scan">
15422              Configure &amp; scan
15423              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
15424            </a>
15425            <p class="card-tip">Full 4-step setup · all options</p>
15426          </div>
15427        </div>
15428        </div>
15429      </div>
15430
15431      <!-- Option 2: Load from config file -->
15432      <div class="option-card-wrap">
15433        <div class="option-card">
15434        <div class="option-icon load-config">
15435          <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>
15436        </div>
15437        <div class="card-body">
15438          <div class="card-left">
15439            <div class="card-text">
15440              <div class="option-title">Load a saved config</div>
15441              <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>
15442              <ul class="feature-list">
15443                <li>All 15 settings restored from the file</li>
15444                <li>Fully editable — change path or output dir</li>
15445                <li>Works with any scan-config.json</li>
15446              </ul>
15447            </div>
15448          </div>
15449          <div class="card-right">
15450            <div class="file-input-wrap">
15451              <button class="btn btn-secondary" id="load-config-btn" type="button">
15452                <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>
15453                Choose config file
15454              </button>
15455              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
15456            </div>
15457            <p class="card-tip" id="config-file-name">Exported after every scan</p>
15458          </div>
15459        </div>
15460        </div>
15461      </div>
15462
15463      <!-- Option 3: Re-scan recent project -->
15464      <div class="option-card-wrap">
15465        <div class="option-card" id="recent-card">
15466        <div class="card-top-row">
15467          <div class="option-icon rescan">
15468            <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>
15469          </div>
15470          <div class="card-body">
15471            <div class="card-left">
15472              <div class="card-text">
15473                <div class="option-title">Re-scan a recent project</div>
15474                <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>
15475                <ul class="feature-list">
15476                  <li>All 15+ settings restored from the saved config</li>
15477                  <li>Path and output dir are editable before running</li>
15478                  <li>Only scans with a saved config appear here</li>
15479                </ul>
15480              </div>
15481            </div>
15482            <div class="card-right">
15483              <div class="rescan-count-box">
15484                <div class="rescan-count-num" id="rescan-count-num">—</div>
15485                <div class="rescan-count-label">saved configs</div>
15486              </div>
15487              <a class="btn btn-secondary" href="/view-reports">
15488                <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>
15489                View all runs
15490              </a>
15491              <p class="card-tip">Opens run history</p>
15492            </div>
15493          </div>
15494        </div>
15495        <div class="section-divider"></div>
15496        <div class="recent-list" id="recent-list">
15497          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
15498        </div>
15499        </div>
15500      </div>
15501
15502    </div>
15503  </div>
15504
15505  <footer class="site-footer">
15506    local code analysis - metrics, history and reports
15507    &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>
15508    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15509    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15510    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15511    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
15512  </footer>
15513
15514  <script nonce="{{ csp_nonce }}">
15515    (function () {
15516      var storageKey = 'oxide-sloc-theme';
15517      var body = document.body;
15518      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15519      var toggle = document.getElementById('theme-toggle');
15520      if (toggle) toggle.addEventListener('click', function () {
15521        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15522        body.classList.toggle('dark-theme', next === 'dark');
15523        try { localStorage.setItem(storageKey, next); } catch(e) {}
15524      });
15525
15526      (function randomizeWatermarks() {
15527        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15528        if (!wms.length) return;
15529        var placed = [];
15530        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; }
15531        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]; }
15532        var half = Math.floor(wms.length / 2);
15533        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; });
15534      })();
15535      (function spawnCodeParticles() {
15536        var container = document.getElementById('code-particles');
15537        if (!container) return;
15538        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'];
15539        var count = 38;
15540        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); }
15541      })();
15542      // Recent scans data injected from server
15543      var recentScans = {{ recent_scans_json|safe }};
15544
15545      function configToParams(cfg) {
15546        var p = new URLSearchParams();
15547        p.set('prefilled', '1');
15548        if (cfg.path) p.set('path', cfg.path);
15549        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
15550        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
15551        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
15552        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
15553        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
15554        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
15555        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
15556        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
15557        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
15558        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
15559        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
15560        if (cfg.report_title) p.set('report_title', cfg.report_title);
15561        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
15562        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
15563        return p;
15564      }
15565
15566      // Build recent scan list (capped at 3 visible entries)
15567      var list = document.getElementById('recent-list');
15568      var noNote = document.getElementById('no-recent-note');
15569      var hasAny = false;
15570      var MAX_RECENT = 3;
15571      if (Array.isArray(recentScans)) {
15572        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
15573        var shown = 0;
15574        validEntries.forEach(function (entry) {
15575          if (shown >= MAX_RECENT) return;
15576          shown++;
15577          hasAny = true;
15578          var item = document.createElement('div');
15579          item.className = 'recent-item';
15580          item.title = 'Restore all settings and open wizard';
15581          item.innerHTML =
15582            '<div class="recent-item-info">' +
15583              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
15584              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
15585            '</div>' +
15586            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
15587          item.addEventListener('click', function () {
15588            var params = configToParams(entry.config);
15589            window.location.href = '/scan?' + params.toString();
15590          });
15591          list.appendChild(item);
15592        });
15593        if (validEntries.length > MAX_RECENT) {
15594          var moreEl = document.createElement('div');
15595          moreEl.className = 'recent-more-link';
15596          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
15597          list.appendChild(moreEl);
15598        }
15599      }
15600      if (hasAny && noNote) noNote.style.display = 'none';
15601      // Update count badge
15602      var countEl = document.getElementById('rescan-count-num');
15603      if (countEl) {
15604        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
15605        countEl.textContent = total > 0 ? total : '0';
15606      }
15607
15608      // Config file loader
15609      var fileInput = document.getElementById('config-file-input');
15610      var fileName = document.getElementById('config-file-name');
15611      if (fileInput) {
15612        fileInput.addEventListener('change', function () {
15613          var file = fileInput.files && fileInput.files[0];
15614          if (!file) return;
15615          if (fileName) fileName.textContent = '✓ ' + file.name;
15616          var reader = new FileReader();
15617          reader.onload = function (e) {
15618            try {
15619              var cfg = JSON.parse(e.target.result);
15620              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
15621              var params = configToParams(cfg);
15622              window.location.href = '/scan?' + params.toString();
15623            } catch (err) {
15624              alert('Could not parse config file: ' + err.message);
15625            }
15626          };
15627          reader.readAsText(file);
15628        });
15629      }
15630
15631      function escHtml(s) {
15632        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
15633      }
15634    })();
15635  </script>
15636  <script nonce="{{ csp_nonce }}">
15637  (function(){
15638    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'}];
15639    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);});}
15640    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15641    function init(){
15642      var btn=document.getElementById('settings-btn');if(!btn)return;
15643      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15644      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>';
15645      document.body.appendChild(m);
15646      var g=document.getElementById('scheme-grid');
15647      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);});
15648      var cl=document.getElementById('settings-close');
15649      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);
15650      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');});
15651      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15652      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15653    }
15654    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15655  }());
15656  </script>
15657  <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>
15658</body>
15659</html>
15660"##,
15661    ext = "html"
15662)]
15663struct ScanSetupTemplate {
15664    version: &'static str,
15665    recent_scans_json: String,
15666    csp_nonce: String,
15667}
15668
15669#[derive(Template)]
15670#[template(
15671    source = r##"
15672<!doctype html>
15673<html lang="en">
15674<head>
15675  <meta charset="utf-8">
15676  <meta name="viewport" content="width=device-width, initial-scale=1">
15677  <title>OxideSLOC | {{ report_title }} | Report</title>
15678  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15679  <style nonce="{{ csp_nonce }}">
15680    :root {
15681      --radius: 18px;
15682      --bg: #f5efe8;
15683      --surface: rgba(255,255,255,0.82);
15684      --surface-2: #fbf7f2;
15685      --surface-3: #efe6dc;
15686      --line: #e6d0bf;
15687      --line-strong: #dcb89f;
15688      --text: #43342d;
15689      --muted: #7b675b;
15690      --muted-2: #a08777;
15691      --nav: #b85d33;
15692      --nav-2: #7a371b;
15693      --accent: #6f9bff;
15694      --accent-2: #4a78ee;
15695      --oxide: #d37a4c;
15696      --oxide-2: #b35428;
15697      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
15698      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
15699      --success-bg: #e8f5ed;
15700      --success-text: #1a8f47;
15701      --info-bg: #eef3ff;
15702      --info-text: #4467d8;
15703    }
15704
15705    body.dark-theme {
15706      --bg: #1b1511;
15707      --surface: #261c17;
15708      --surface-2: #2d221d;
15709      --surface-3: #372922;
15710      --line: #524238;
15711      --line-strong: #6c5649;
15712      --text: #f5ece6;
15713      --muted: #c7b7aa;
15714      --muted-2: #aa9485;
15715      --nav: #b85d33;
15716      --nav-2: #7a371b;
15717      --accent: #6f9bff;
15718      --accent-2: #4a78ee;
15719      --oxide: #d37a4c;
15720      --oxide-2: #b35428;
15721      --shadow: 0 18px 42px rgba(0,0,0,0.28);
15722      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
15723      --success-bg: #163927;
15724      --success-text: #8fe2a8;
15725      --info-bg: #1c2847;
15726      --info-text: #a9c1ff;
15727    }
15728
15729    * { box-sizing: border-box; }
15730    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); }
15731    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15732    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15733    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15734    .top-nav, .page { position: relative; z-index: 2; }
15735    .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); }
15736    .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; }
15737    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15738    .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)); }
15739    .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; }
15740    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15741    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15742    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15743    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15744    .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; }
15745    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15746    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15747    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15748    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15749    @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; } }
15750    .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; }
15751    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15752    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15753    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15754    .theme-toggle .icon-sun { display:none; }
15755    body.dark-theme .theme-toggle .icon-sun { display:block; }
15756    body.dark-theme .theme-toggle .icon-moon { display:none; }
15757    .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;}
15758    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15759    .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);}
15760    .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;}
15761    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15762    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15763    .settings-modal-body{padding:14px 16px 16px;}
15764    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15765    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15766    .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;}
15767    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15768    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15769    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15770    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15771    .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;}
15772    .tz-select:focus{border-color:var(--oxide);}
15773    .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; }
15774    .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;}
15775    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
15776    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
15777    .hero, .panel { padding: 22px; }
15778    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
15779    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15780    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
15781    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
15782    .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; }
15783    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
15784    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
15785    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
15786    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
15787    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
15788    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
15789    .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; }
15790    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
15791    .delta-card-val { font-size:16px; font-weight:800; }
15792    .delta-card-val.pos { color:#1e7e34; }
15793    .delta-card-val.neg { color:var(--neg); }
15794    .delta-card-val.mod { color:#b35428; }
15795    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
15796    .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; }
15797    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15798    .delta-card-inline:hover .delta-card-tip { opacity:1; }
15799    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
15800    .compare-ts { font-size:13px; color:var(--muted); }
15801    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
15802    .compare-arrow { color: var(--muted); }
15803    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
15804    .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; }
15805    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
15806    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
15807    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
15808    .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; }
15809    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
15810    .run-mgmt-card .action-buttons { justify-content:center; }
15811    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
15812    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
15813    .button, .copy-button {
15814      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;
15815    }
15816    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
15817    @keyframes spin { to { transform: rotate(360deg); } }
15818    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
15819    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
15820    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
15821    .path-item strong { display: block; margin-bottom: 6px; }
15822    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
15823    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
15824    .path-subitem { flex: 1; }
15825    .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); }
15826    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); }
15827    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
15828    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
15829    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
15830    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
15831    th { color: var(--muted); font-weight: 700; }
15832    tr:last-child td { border-bottom: none; }
15833    #subm-tbl col:nth-child(1){width:15%;}
15834    #subm-tbl col:nth-child(2){width:31%;}
15835    #subm-tbl col:nth-child(3){width:9%;}
15836    #subm-tbl col:nth-child(4){width:9%;}
15837    #subm-tbl col:nth-child(5){width:9%;}
15838    #subm-tbl col:nth-child(6){width:9%;}
15839    #subm-tbl col:nth-child(7){width:9%;}
15840    #subm-tbl col:nth-child(8){width:9%;}
15841    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
15842    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
15843    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
15844    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
15845    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
15846    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
15847    .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; }
15848    .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; }
15849    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
15850    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
15851    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
15852    .muted { color: var(--muted); }
15853    /* Run-ID chip row (mirrors HTML report) */
15854    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
15855    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
15856    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
15857    .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; }
15858    .run-id-chip[data-copy] { cursor:pointer; }
15859    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
15860    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
15861    .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; }
15862    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
15863    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15864    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
15865    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
15866    a.commit-link-value { color:inherit; text-decoration:none; }
15867    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
15868    .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; }
15869    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15870    .run-id-chip:hover .chip-tooltip { opacity:1; }
15871    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
15872    .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; }
15873    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
15874    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
15875    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
15876    /* Meta chips row */
15877    .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%; }
15878    .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; }
15879    .meta-chip:last-child { border-right:none; }
15880    .meta-chip b { color:var(--text); font-weight:700; }
15881    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15882    .site-footer a{color:var(--muted);}
15883    .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; }
15884    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
15885    .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; }
15886    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
15887    /* Stat chips (matches HTML report) */
15888    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
15889    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
15890    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15891    .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; }
15892    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
15893    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
15894    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
15895    .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; }
15896    .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; }
15897    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15898    .stat-chip:hover .stat-chip-tip { opacity:1; }
15899    /* Submodule panel */
15900    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
15901    /* Metrics tables stack */
15902    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
15903    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
15904    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
15905    .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)); }
15906    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
15907    /* Metrics table */
15908    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
15909    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
15910    .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; }
15911    .metrics-table thead th:not(:first-child) { text-align: right; }
15912    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
15913    .metrics-table tbody tr:last-child td { border-bottom: none; }
15914    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
15915    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
15916    .metrics-table tbody tr:hover td { background: var(--surface-2); }
15917    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
15918    .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; }
15919    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
15920    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
15921    .mt-val-pos { color: var(--pos); font-weight: 700; }
15922    .mt-val-neg { color: var(--neg); font-weight: 700; }
15923    .mt-val-zero { color: var(--muted); }
15924    .mt-val-mod { color: var(--oxide-2); }
15925    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
15926    @media (max-width: 1180px) {
15927      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
15928      .nav-project-slot, .nav-status { justify-content:flex-start; }
15929      .hero-top { flex-direction: column; }
15930      .run-mgmt-strip { flex-direction: column; }
15931    }
15932    .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;}
15933    @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));}}
15934    .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;}
15935    /* ── Result-page chart controls ─────────────────────────────────────────── */
15936    .r-chart-section{margin-bottom:24px;}
15937    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
15938    .section-pair > .panel{flex-shrink:0;}
15939    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
15940    .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;}
15941    .r-chart-select:focus{border-color:var(--accent);}
15942    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
15943    .r-chart-container svg{display:block;width:100%;height:auto;}
15944    .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;}
15945    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
15946    .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;}
15947    .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);}
15948    .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;}
15949    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}
15950    .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;}
15951    .r-chart-modal-close:hover{opacity:.7;}
15952    body.dark-theme .r-chart-modal{background:var(--surface);}
15953    .r-chart-container .rchit,.r-expand-modal-chart .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
15954    .r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover{opacity:.75;filter:brightness(1.14);}
15955    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
15956    .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;}
15957    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
15958    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
15959    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
15960    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
15961    #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;}
15962    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
15963    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
15964    .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;}
15965    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
15966    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
15967    .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;}
15968    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
15969    .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%;}
15970    .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%;}
15971    body.has-report-banner .top-nav{top:27px;}
15972    body.has-report-banner{padding-bottom:27px;}
15973  </style>
15974</head>
15975<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
15976  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" />
15991  </div>
15992  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15993  {% if let Some(banner) = report_header_footer %}
15994  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
15995  {% endif %}
15996  <div class="top-nav">
15997    <div class="top-nav-inner">
15998      <a class="brand" href="/">
15999        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16000        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16001      </a>
16002      <div class="nav-project-slot">
16003        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
16004      </div>
16005      <div class="nav-status">
16006        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
16007        <div class="nav-dropdown">
16008          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
16009          <div class="nav-dropdown-menu">
16010            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
16011          </div>
16012        </div>
16013        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
16014        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16015        <div class="nav-dropdown">
16016          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
16017          <div class="nav-dropdown-menu">
16018            <a href="/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>
16019          </div>
16020        </div>
16021        <div class="server-status-wrap" id="server-status-wrap">
16022          <div class="nav-pill server-online-pill" id="server-status-pill">
16023            <span class="status-dot" id="status-dot"></span>
16024            <span id="server-status-label">Server</span>
16025            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16026          </div>
16027          <div class="server-status-tip">
16028            OxideSLOC is running — accessible on your network.
16029            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16030          </div>
16031        </div>
16032        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16033          <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>
16034        </button>
16035        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
16036          <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>
16037          <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>
16038        </button>
16039      </div>
16040    </div>
16041  </div>
16042
16043  <div class="page">
16044    <section class="hero">
16045      <div class="hero-top">
16046        <div>
16047          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
16048            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
16049            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
16050            <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>
16051          </div>
16052        </div>
16053        <div class="hero-quick-actions">
16054          {% if server_mode %}
16055          <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>
16056          {% else %}
16057          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
16058          {% endif %}
16059          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
16060          {% if !server_mode %}
16061          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
16062          {% endif %}
16063        </div>
16064      </div>
16065
16066      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
16067      <div class="run-id-row">
16068        <span class="run-id-chip" data-copy="{{ run_id }}">
16069          <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>
16070          <span class="run-id-chip-value">{{ run_id }}</span>
16071          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
16072        </span>
16073        {% match git_commit_long %}
16074          {% when Some with (long_sha) %}
16075          {% match git_commit_url %}
16076            {% when Some with (commit_url) %}
16077            <span class="run-id-chip" data-copy="{{ long_sha }}">
16078              <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>
16079              <a href="{{ commit_url }}" target="_blank" rel="noopener" class="run-id-chip-value commit-link-value" onclick="event.stopPropagation()">{{ long_sha }}</a>
16080              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
16081            </span>
16082            {% when None %}
16083            <span class="run-id-chip" data-copy="{{ long_sha }}">
16084              <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>
16085              <span class="run-id-chip-value">{{ long_sha }}</span>
16086              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
16087            </span>
16088          {% endmatch %}
16089          {% when None %}
16090          <span class="run-id-chip muted-chip">
16091            <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>
16092            <span class="run-id-chip-value">Not detected</span>
16093            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
16094          </span>
16095        {% endmatch %}
16096        {% match git_branch %}
16097          {% when Some with (branch) %}
16098          <span class="run-id-chip">
16099            <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>
16100            <span class="run-id-chip-value">{{ branch }}</span>
16101            <span class="chip-tooltip">Git branch active at scan time</span>
16102          </span>
16103          {% when None %}
16104          <span class="run-id-chip muted-chip">
16105            <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>
16106            <span class="run-id-chip-value">Not detected</span>
16107            <span class="chip-tooltip">No Git branch was found for this scan</span>
16108          </span>
16109        {% endmatch %}
16110        {% match git_author %}
16111          {% when Some with (author) %}
16112          <span class="run-id-chip" data-author="{{ author }}">
16113            <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>
16114            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
16115            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
16116          </span>
16117          {% when None %}
16118          <span class="run-id-chip muted-chip">
16119            <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>
16120            <span class="run-id-chip-value">Not detected</span>
16121            <span class="chip-tooltip">No commit author was found for this scan</span>
16122          </span>
16123        {% endmatch %}
16124      </div>
16125
16126      <!-- Scan metadata row -->
16127      <div class="meta">
16128        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
16129        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
16130        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
16131        <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
16132        <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
16133      </div>
16134
16135      <!-- 12 summary stat chips -->
16136      <div class="summary-strip">
16137        <div class="stat-chip" data-raw="{{ physical_lines }}">
16138          <div class="stat-chip-label">Physical lines</div>
16139          <div class="stat-chip-val">{{ physical_lines }}</div>
16140          <div class="stat-chip-exact"></div>
16141          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
16142        </div>
16143        <div class="stat-chip" data-raw="{{ code_lines }}">
16144          <div class="stat-chip-label">Code</div>
16145          <div class="stat-chip-val">{{ code_lines }}</div>
16146          <div class="stat-chip-exact"></div>
16147          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
16148        </div>
16149        <div class="stat-chip" data-raw="{{ comment_lines }}">
16150          <div class="stat-chip-label">Comments</div>
16151          <div class="stat-chip-val">{{ comment_lines }}</div>
16152          <div class="stat-chip-exact"></div>
16153          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
16154        </div>
16155        <div class="stat-chip" data-raw="{{ blank_lines }}">
16156          <div class="stat-chip-label">Blank</div>
16157          <div class="stat-chip-val">{{ blank_lines }}</div>
16158          <div class="stat-chip-exact"></div>
16159          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
16160        </div>
16161        <div class="stat-chip" data-raw="{{ mixed_lines }}">
16162          <div class="stat-chip-label">Mixed separate</div>
16163          <div class="stat-chip-val">{{ mixed_lines }}</div>
16164          <div class="stat-chip-exact"></div>
16165          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
16166        </div>
16167        <div class="stat-chip" data-raw="{{ functions }}">
16168          <div class="stat-chip-label">Functions</div>
16169          <div class="stat-chip-val">{{ functions }}</div>
16170          <div class="stat-chip-exact"></div>
16171          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
16172        </div>
16173        <div class="stat-chip" data-raw="{{ classes }}">
16174          <div class="stat-chip-label">Classes / Types</div>
16175          <div class="stat-chip-val">{{ classes }}</div>
16176          <div class="stat-chip-exact"></div>
16177          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
16178        </div>
16179        <div class="stat-chip" data-raw="{{ variables }}">
16180          <div class="stat-chip-label">Variables</div>
16181          <div class="stat-chip-val">{{ variables }}</div>
16182          <div class="stat-chip-exact"></div>
16183          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
16184        </div>
16185        <div class="stat-chip" data-raw="{{ imports }}">
16186          <div class="stat-chip-label">Imports</div>
16187          <div class="stat-chip-val">{{ imports }}</div>
16188          <div class="stat-chip-exact"></div>
16189          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
16190        </div>
16191        <div class="stat-chip" data-raw="{{ test_count }}">
16192          <div class="stat-chip-label">Tests</div>
16193          <div class="stat-chip-val">{{ test_count }}</div>
16194          <div class="stat-chip-exact"></div>
16195          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
16196        </div>
16197        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
16198          <div class="stat-chip-label">Code density</div>
16199          <div class="stat-chip-val stat-chip-density-val">—</div>
16200          <div class="stat-chip-exact"></div>
16201          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
16202        </div>
16203        <div class="stat-chip" data-raw="{{ files_analyzed }}">
16204          <div class="stat-chip-label">Files analyzed</div>
16205          <div class="stat-chip-val">{{ files_analyzed }}</div>
16206          <div class="stat-chip-exact"></div>
16207          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
16208        </div>
16209      </div>
16210
16211      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
16212      <div class="compare-banner">
16213        <div class="compare-banner-body">
16214          <div class="compare-banner-meta">
16215            <span class="compare-label">Previous scan</span>
16216            <span class="compare-ts">{{ prev_ts }}</span>
16217            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
16218            {% if let Some(prev_code) = prev_run_code_lines %}
16219            <div class="compare-banner-stats" style="margin-top:4px;">
16220              <span>Code before: <strong>{{ prev_code }}</strong></span>
16221              <span class="compare-arrow">→</span>
16222              <span>Code now: <strong>{{ code_lines }}</strong></span>
16223              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
16224              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
16225            </div>
16226            {% endif %}
16227          </div>
16228          {% if delta_lines_added.is_some() %}
16229          <div class="delta-cards-inline">
16230            <div class="delta-card-inline">
16231              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
16232              <div class="delta-card-lbl">lines added</div>
16233              <div class="delta-card-tip">Code lines added since the previous scan</div>
16234            </div>
16235            <div class="delta-card-inline">
16236              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
16237              <div class="delta-card-lbl">lines removed</div>
16238              <div class="delta-card-tip">Code lines removed since the previous scan</div>
16239            </div>
16240            <div class="delta-card-inline">
16241              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
16242              <div class="delta-card-lbl">unmodified lines</div>
16243              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
16244            </div>
16245            <div class="delta-card-inline">
16246              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
16247              <div class="delta-card-lbl">files modified</div>
16248              <div class="delta-card-tip">Files with at least one line changed</div>
16249            </div>
16250            <div class="delta-card-inline">
16251              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
16252              <div class="delta-card-lbl">files added</div>
16253              <div class="delta-card-tip">New files added since the previous scan</div>
16254            </div>
16255            <div class="delta-card-inline">
16256              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
16257              <div class="delta-card-lbl">files removed</div>
16258              <div class="delta-card-tip">Files deleted since the previous scan</div>
16259            </div>
16260            <div class="delta-card-inline">
16261              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
16262              <div class="delta-card-lbl">files unchanged</div>
16263              <div class="delta-card-tip">Files with no changes since the previous scan</div>
16264            </div>
16265          </div>
16266          {% else %}
16267          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
16268            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
16269          </p>
16270          {% endif %}
16271          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
16272        </div>
16273      </div>
16274      {% endif %}{% endif %}
16275
16276      <div class="action-grid">
16277        <div class="action-card">
16278          <h3>HTML report</h3>
16279          <div class="action-buttons">
16280            {% match html_url %}
16281              {% when Some with (url) %}
16282                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
16283              {% when None %}{% endmatch %}
16284            {% match html_download_url %}
16285              {% when Some with (url) %}
16286                <a class="button secondary" href="{{ url }}">Download HTML</a>
16287              {% when None %}{% endmatch %}
16288            {% match html_path %}
16289              {% when Some with (_path) %}{% when None %}{% endmatch %}
16290            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
16291          </div>
16292        </div>
16293        <div class="action-card">
16294          <h3>PDF report</h3>
16295          <div class="action-buttons">
16296            {% match pdf_url %}
16297              {% when Some with (url) %}
16298                {% if pdf_generating %}
16299                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
16300                    <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>
16301                    Generating PDF…
16302                  </button>
16303                {% else %}
16304                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
16305                {% endif %}
16306              {% when None %}
16307                {% match html_url %}
16308                  {% when Some with (_hurl) %}
16309                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
16310                    <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>
16311                  {% when None %}
16312                    <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;">
16313                      PDF and HTML reports were not generated for this run. Re-run with HTML or PDF output enabled.
16314                    </p>
16315                {% endmatch %}
16316            {% endmatch %}
16317            {% match pdf_download_url %}
16318              {% when Some with (url) %}
16319                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
16320              {% when None %}{% endmatch %}
16321            {% match pdf_url %}
16322              {% when Some with (_) %}
16323                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
16324              {% when None %}{% endmatch %}
16325          </div>
16326        </div>
16327        <div class="action-card">
16328          <h3>JSON result</h3>
16329          <div class="action-buttons">
16330            {% match json_url %}
16331              {% when Some with (url) %}
16332                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
16333              {% when None %}{% endmatch %}
16334            {% match json_download_url %}
16335              {% when Some with (url) %}
16336                <a class="button secondary" href="{{ url }}">Download JSON</a>
16337              {% when None %}{% endmatch %}
16338            {% match json_path %}
16339              {% when Some with (_path) %}
16340                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
16341              {% when None %}
16342                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
16343              {% endmatch %}
16344          </div>
16345        </div>
16346        <div class="action-card">
16347          <h3>Scan config</h3>
16348          <div class="action-buttons">
16349            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
16350            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
16351            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
16352          </div>
16353        </div>
16354        {% if confluence_configured %}
16355        <div class="action-card" id="confluenceCard">
16356          <h3>Confluence</h3>
16357          <div class="action-buttons">
16358            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
16359            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
16360          </div>
16361          <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>
16362        </div>
16363        {% endif %}
16364      </div>
16365      <div class="run-mgmt-strip">
16366        <div class="run-mgmt-card">
16367          <h3>Download bundle</h3>
16368          <div class="action-buttons">
16369            <button class="button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
16370          </div>
16371          <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>
16372        </div>
16373        <div class="run-mgmt-card" id="delete-run-card">
16374          <h3>Delete run</h3>
16375          <div class="action-buttons">
16376            <button class="button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete this run</button>
16377          </div>
16378          <p class="action-empty-note" style="margin-top:6px;">Permanently removes all artifacts for this run from disk. This action cannot be undone.</p>
16379        </div>
16380      </div>
16381      {% if confluence_configured %}
16382      <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;">
16383        <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);">
16384          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
16385          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
16386          <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;">
16387          <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>
16388          <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;">
16389          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16390          <div style="display:flex;gap:10px;justify-content:flex-end;">
16391            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
16392            <button class="button" id="confSubmitBtn" type="button">Post</button>
16393          </div>
16394        </div>
16395      </div>
16396      {% endif %}
16397      <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;">
16398        <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);">
16399          <div style="font-size:16px;font-weight:800;margin-bottom:10px;color:#b23030;">Delete run — irreversible</div>
16400          <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>
16401          <div id="delete-run-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16402          <div style="display:flex;gap:10px;justify-content:flex-end;">
16403            <button class="button secondary" id="delete-run-cancel" type="button">Cancel</button>
16404            <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;">Yes, delete permanently</button>
16405          </div>
16406        </div>
16407      </div>
16408      {% if !submodule_rows.is_empty() %}
16409      <div class="submodule-panel">
16410        <div class="toolbar-row">
16411          <div>
16412            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
16413            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
16414          </div>
16415          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
16416        </div>
16417        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
16418        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
16419          <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>
16420          <thead>
16421            <tr>
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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Submodule</th>
16423              <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>
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;">Files</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;">Physical</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;">Code</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;">Comments</th>
16428              <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>
16429              <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>
16430            </tr>
16431          </thead>
16432          <tbody>
16433            {% for row in submodule_rows %}
16434            <tr>
16435              <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>
16436              <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>
16437              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
16438              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
16439              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
16440              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
16441              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
16442              <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>
16443            </tr>
16444            {% endfor %}
16445          </tbody>
16446        </table>
16447        </div>
16448      </div>
16449      {% endif %}
16450
16451      <div class="metrics-tables-stack">
16452
16453        <div class="metrics-table-wrap">
16454          <div class="metrics-table-title">Files</div>
16455          <table class="metrics-table">
16456            <thead>
16457              <tr>
16458                <th>Metric</th>
16459                <th>This Run</th>
16460                <th>Previous</th>
16461                <th>Change</th>
16462              </tr>
16463            </thead>
16464            <tbody>
16465              <tr>
16466                <td>Files analyzed</td>
16467                <td class="mt-val-large">{{ files_analyzed }}</td>
16468                <td>{{ prev_fa_str }}</td>
16469                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
16470              </tr>
16471              <tr>
16472                <td>Files skipped</td>
16473                <td>{{ files_skipped }}</td>
16474                <td>{{ prev_fs_str }}</td>
16475                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
16476              </tr>
16477              <tr>
16478                <td>Files modified</td>
16479                <td class="mt-val-na">—</td>
16480                <td class="mt-val-na">—</td>
16481                <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>
16482              </tr>
16483              <tr>
16484                <td>Files unchanged</td>
16485                <td class="mt-val-na">—</td>
16486                <td class="mt-val-na">—</td>
16487                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
16488              </tr>
16489            </tbody>
16490          </table>
16491        </div>
16492
16493        <div class="metrics-table-wrap">
16494          <div class="metrics-table-title">Line Counts</div>
16495          <table class="metrics-table">
16496            <thead>
16497              <tr>
16498                <th>Metric</th>
16499                <th>This Run</th>
16500                <th>Previous</th>
16501                <th>Change</th>
16502              </tr>
16503            </thead>
16504            <tbody>
16505              <tr>
16506                <td>Physical lines</td>
16507                <td class="mt-val-large">{{ physical_lines }}</td>
16508                <td>{{ prev_pl_str }}</td>
16509                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
16510              </tr>
16511              <tr>
16512                <td>Code lines</td>
16513                <td class="mt-val-large">{{ code_lines }}</td>
16514                <td>{{ prev_cl_str }}</td>
16515                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
16516              </tr>
16517              <tr>
16518                <td>Comment lines</td>
16519                <td>{{ comment_lines }}</td>
16520                <td>{{ prev_cml_str }}</td>
16521                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
16522              </tr>
16523              <tr>
16524                <td>Blank lines</td>
16525                <td>{{ blank_lines }}</td>
16526                <td>{{ prev_bl_str }}</td>
16527                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
16528              </tr>
16529              <tr>
16530                <td>Mixed (separate)</td>
16531                <td>{{ mixed_lines }}</td>
16532                <td class="mt-val-na">—</td>
16533                <td class="mt-val-na">—</td>
16534              </tr>
16535            </tbody>
16536          </table>
16537        </div>
16538
16539        <div class="metrics-tables-lower">
16540          <div class="metrics-table-wrap">
16541            <div class="metrics-table-title">Code Structure</div>
16542            <table class="metrics-table">
16543              <thead>
16544                <tr>
16545                  <th>Metric</th>
16546                  <th>This Run</th>
16547                </tr>
16548              </thead>
16549              <tbody>
16550                <tr>
16551                  <td>Functions</td>
16552                  <td>{{ functions }}</td>
16553                </tr>
16554                <tr>
16555                  <td>Classes / Types</td>
16556                  <td>{{ classes }}</td>
16557                </tr>
16558                <tr>
16559                  <td>Variables</td>
16560                  <td>{{ variables }}</td>
16561                </tr>
16562                <tr>
16563                  <td>Imports</td>
16564                  <td>{{ imports }}</td>
16565                </tr>
16566              </tbody>
16567            </table>
16568          </div>
16569
16570          <div class="metrics-table-wrap">
16571            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
16572            <table class="metrics-table">
16573              <thead>
16574                <tr>
16575                  <th>Metric</th>
16576                  <th>Change</th>
16577                </tr>
16578              </thead>
16579              <tbody>
16580                <tr>
16581                  <td>Lines added</td>
16582                  <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>
16583                </tr>
16584                <tr>
16585                  <td>Lines removed</td>
16586                  <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>
16587                </tr>
16588                <tr>
16589                  <td>Lines modified (net)</td>
16590                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
16591                </tr>
16592                <tr>
16593                  <td>Lines unmodified</td>
16594                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
16595                </tr>
16596              </tbody>
16597            </table>
16598          </div>
16599        </div>
16600
16601      </div>
16602
16603      <div class="path-list">
16604        <div class="path-item">
16605          <div class="path-item-label">Project path</div>
16606          <code>{{ project_path }}</code>
16607        </div>
16608        <div class="path-item">
16609          <div class="path-item-label">Git branch</div>
16610          {% if let Some(branch) = git_branch %}
16611          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
16612          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
16613          {% else %}
16614          <code style="color:var(--muted)">—</code>
16615          {% endif %}
16616        </div>
16617        <div class="path-item">
16618          <div class="path-item-label">Output folder</div>
16619          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
16620        </div>
16621        <div class="path-item">
16622          <div class="path-item-label">Run ID</div>
16623          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
16624            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
16625            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
16626          </div>
16627        </div>
16628      </div>
16629    </section>
16630
16631    <div class="section-pair">
16632    <section class="panel">
16633        <div class="toolbar-row">
16634          <div>
16635            <h2>Language breakdown</h2>
16636            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
16637          </div>
16638        </div>
16639        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
16640    </section>
16641
16642    <section class="panel r-chart-section">
16643      <div class="toolbar-row" style="margin-bottom:16px;">
16644        <div>
16645          <h2>Visualizations</h2>
16646          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
16647        </div>
16648      </div>
16649
16650      <div class="r-viz-grid">
16651        <div class="r-viz-card">
16652          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16653            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
16654            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16655          </div>
16656          <div class="r-chart-tab-bar">
16657            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
16658            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
16659          </div>
16660          <div class="r-chart-container" id="r-composition-chart"></div>
16661        </div>
16662        <div class="r-viz-card">
16663          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16664            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
16665            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16666          </div>
16667          <div class="r-chart-container" id="r-scatter-chart"></div>
16668        </div>
16669        {% if has_semantic_data %}
16670        <div class="r-viz-card">
16671          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16672            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
16673            <select class="r-chart-select" id="r-semantic-metric">
16674              <option value="functions">Functions</option>
16675              <option value="classes">Classes</option>
16676              <option value="variables">Variables</option>
16677              <option value="imports">Imports</option>
16678            </select>
16679            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16680          </div>
16681          <div class="r-chart-container" id="r-semantic-chart"></div>
16682        </div>
16683        {% endif %}
16684        <div class="r-viz-card">
16685          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16686            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
16687            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16688          </div>
16689          <div class="r-chart-container" id="r-density-chart"></div>
16690        </div>
16691        <div class="r-viz-card">
16692          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16693            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
16694            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16695          </div>
16696          <div class="r-chart-container" id="r-avglines-chart"></div>
16697        </div>
16698        <div class="r-viz-card">
16699          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
16700            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
16701            <select class="r-chart-select" id="r-sub-metric">
16702              <option value="code">Code Lines</option>
16703              <option value="comment">Comments</option>
16704              <option value="blank">Blank Lines</option>
16705              <option value="physical">Physical Lines</option>
16706              <option value="files">Files</option>
16707            </select>
16708            <select class="r-chart-select" id="r-sub-sort">
16709              <option value="desc">Value ↓</option>
16710              <option value="asc">Value ↑</option>
16711              <option value="name">Name A→Z</option>
16712            </select>
16713            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16714          </div>
16715          <div class="r-chart-container" id="r-submodule-chart"></div>
16716        </div>
16717      </div>
16718
16719    </section>
16720    </div>
16721
16722  </div>
16723
16724  <div id="r-tt" aria-hidden="true"></div>
16725
16726  <script nonce="{{ csp_nonce }}">
16727    (function () {
16728      var body = document.body;
16729      var themeToggle = document.getElementById('theme-toggle');
16730      var storageKey = 'oxide-sloc-theme';
16731
16732      function applyTheme(theme) {
16733        body.classList.toggle('dark-theme', theme === 'dark');
16734      }
16735
16736      function loadSavedTheme() {
16737        try {
16738          var saved = localStorage.getItem(storageKey);
16739          if (saved === 'dark' || saved === 'light') {
16740            applyTheme(saved);
16741          }
16742        } catch (e) {}
16743      }
16744
16745      if (themeToggle) {
16746        themeToggle.addEventListener('click', function () {
16747          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
16748          applyTheme(nextTheme);
16749          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
16750        });
16751      }
16752
16753      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
16754        button.addEventListener('click', function () {
16755          var value = button.getAttribute('data-copy-value') || '';
16756          if (!value) return;
16757          var originalText = button.textContent;
16758          function flashSuccess() {
16759            button.textContent = 'Copied!';
16760            setTimeout(function () { button.textContent = originalText; }, 1800);
16761          }
16762          function flashFail() {
16763            button.textContent = 'Copy failed';
16764            setTimeout(function () { button.textContent = originalText; }, 2000);
16765          }
16766          if (navigator.clipboard && navigator.clipboard.writeText) {
16767            navigator.clipboard.writeText(value).then(flashSuccess, function () {
16768              fallbackCopy(value, flashSuccess, flashFail);
16769            });
16770          } else {
16771            fallbackCopy(value, flashSuccess, flashFail);
16772          }
16773        });
16774      });
16775      function fallbackCopy(text, onSuccess, onFail) {
16776        try {
16777          var ta = document.createElement('textarea');
16778          ta.value = text;
16779          ta.style.position = 'fixed';
16780          ta.style.top = '-9999px';
16781          ta.style.left = '-9999px';
16782          document.body.appendChild(ta);
16783          ta.focus();
16784          ta.select();
16785          var ok = document.execCommand('copy');
16786          document.body.removeChild(ta);
16787          if (ok) { onSuccess(); } else { onFail(); }
16788        } catch (e) { onFail(); }
16789      }
16790
16791      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16792        btn.addEventListener('click', function () {
16793          var folder = btn.getAttribute('data-folder') || '';
16794          if (!folder) return;
16795          fetch('/open-path?path=' + encodeURIComponent(folder))
16796            .then(function (r) { return r.json(); })
16797            .then(function (d) {
16798              if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16799            })
16800            .catch(function () {});
16801        });
16802      });
16803
16804      loadSavedTheme();
16805
16806      // ── Compact number formatting for stat chips ──────────────────────────
16807      (function(){
16808        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();}
16809        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
16810          var raw=parseInt(chip.getAttribute('data-raw'),10);
16811          if(isNaN(raw))return;
16812          var valEl=chip.querySelector('.stat-chip-val');
16813          if(valEl)valEl.textContent=fmt(raw);
16814          var exactEl=chip.querySelector('.stat-chip-exact');
16815          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
16816        });
16817        // Code density chip
16818        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
16819          var code=parseInt(chip.getAttribute('data-code'),10);
16820          var phys=parseInt(chip.getAttribute('data-physical'),10);
16821          if(isNaN(code)||isNaN(phys)||phys===0)return;
16822          var pct=(code/phys*100).toFixed(1)+'%';
16823          var valEl=chip.querySelector('.stat-chip-val');
16824          if(valEl)valEl.textContent=pct;
16825        });
16826        // Populate author handle from data-author attribute
16827        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
16828          var author=chip.getAttribute('data-author');
16829          var el=chip.querySelector('.author-handle');
16830          if(el)el.textContent='/'+author.replace(/\s+/g,'');
16831        });
16832        // Click-to-copy on run-id-chip elements
16833        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
16834          chip.addEventListener('click',function(){
16835            var val=chip.getAttribute('data-copy');
16836            if(!val)return;
16837            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
16838            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);}
16839            chip.classList.add('chip-copied-flash');
16840            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
16841          });
16842        });
16843      })();
16844
16845      // ── Shared tooltip for all result-page charts ─────────────────────────
16846      var rTT=(function(){
16847        var el=document.getElementById('r-tt');
16848        if(!el)return{s:function(){},h:function(){},m:function(){}};
16849        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
16850        function hide(){el.style.display='none';}
16851        function move(e){
16852          var x=e.clientX+16,y=e.clientY-12;
16853          var r=el.getBoundingClientRect();
16854          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
16855          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
16856          el.style.left=x+'px';el.style.top=y+'px';
16857        }
16858        return{s:show,h:hide,m:move};
16859      })();
16860      window.rTT=rTT;
16861
16862      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
16863      (function(){
16864        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16865        document.addEventListener('mouseover',function(e){
16866          var t=e.target;
16867          while(t&&t.getAttribute){
16868            var l=t.getAttribute('data-ttl');
16869            if(l!==null){
16870              var v=t.getAttribute('data-ttv')||'';
16871              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
16872              return;
16873            }
16874            t=t.parentNode;
16875          }
16876        });
16877        document.addEventListener('mouseout',function(e){
16878          var t=e.target;
16879          while(t&&t.getAttribute){
16880            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
16881            t=t.parentNode;
16882          }
16883        });
16884        document.addEventListener('mousemove',function(e){
16885          var el=document.getElementById('r-tt');
16886          if(el&&el.style.display!=='none')rTT.m(e);
16887        });
16888      })();
16889
16890      // ── Language overview charts ───────────────────────────────────────────
16891      (function(){
16892        var D={{ lang_chart_json|safe }};
16893        if(!D||!D.length)return;
16894        var el=document.getElementById('result-lang-charts');
16895        if(!el)return;
16896        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16897        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
16898        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16899        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();}
16900        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16901        function px(n){return Math.round(n);}
16902        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+'"';}
16903        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
16904
16905        // Donut chart — height matches the stacked-bar chart so both panels align
16906        var rHb_d=28;
16907        var DH=Math.max(220,D.length*rHb_d+32);
16908        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
16909        var legX=204,DW=360;
16910        var legCount=D.length;
16911        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
16912        var legYStart=Math.round((DH-legCount*legSpacing)/2);
16913        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">';
16914        if(D.length===1){
16915          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
16916          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+'"/>';
16917        } else {
16918          var ang=-Math.PI/2;
16919          D.forEach(function(d,i){
16920            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
16921            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
16922            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
16923            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
16924            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
16925            var pct=Math.round(d.code/tot*100);
16926            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"/>';
16927            ang+=sw;
16928          });
16929        }
16930        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
16931        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
16932        D.forEach(function(d,i){
16933          var ly=legYStart+i*legSpacing;
16934          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
16935          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
16936        });
16937        ds+='</svg>';
16938
16939        // Horizontal stacked-bar chart — fills container width
16940        var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
16941        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
16942        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">';
16943        D.forEach(function(d,i){
16944          var y=6+i*rHb,x=LW;
16945          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
16946          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>';
16947          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;
16948          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;
16949          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"/>';
16950          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>';
16951        });
16952        var ly=SH-14;
16953        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>';
16954        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>';
16955        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>';
16956        bs+='</svg>';
16957        el.innerHTML='<div class="r-lang-overview">'+
16958          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
16959          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
16960        '</div>';
16961      })();
16962
16963      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
16964      (function(){
16965        var LANG_D={{ lang_chart_json|safe }};
16966        var SCAT_D={{ scatter_chart_json|safe }};
16967        var SEM_D={{ semantic_chart_json|safe }};
16968        var SUB_D={{ submodule_chart_json|safe }};
16969        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
16970        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16971        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();}
16972        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16973        function px(n){return Math.round(n);}
16974        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+'"';}
16975
16976        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
16977        function renderCompositionInEl(el,mode,shOvr){
16978          if(!el||!LANG_D||!LANG_D.length)return;
16979          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16980          var LW=110,SH=shOvr||224;
16981          var svgW=Math.max(320,el.offsetWidth||480);
16982          var BW=Math.max(120,svgW-LW-80);
16983          var legendH=24,topPad=4;
16984          var n=LANG_D.length||1;
16985          var rowTotal=Math.floor((SH-legendH-topPad)/n);
16986          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16987          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">';
16988          if(mode==='pct'){
16989            LANG_D.forEach(function(d,i){
16990              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
16991              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
16992              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16993              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>';
16994              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;
16995              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;
16996              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+'"/>';
16997              var pct=Math.round((d.code||0)/tot2*100);
16998              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
16999            });
17000          } else {
17001            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
17002            LANG_D.forEach(function(d,i){
17003              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
17004              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
17005              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>';
17006              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;
17007              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;
17008              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+'"/>';
17009              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>';
17010            });
17011          }
17012          var ly=SH-legendH+4;
17013          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>';
17014          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>';
17015          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>';
17016          s+='</svg>';
17017          el.innerHTML=s;
17018        }
17019        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
17020        renderComposition('abs');
17021        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
17022          btn.addEventListener('click',function(){
17023            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
17024            btn.classList.add('active');
17025            renderComposition(btn.getAttribute('data-rcomp'));
17026          });
17027        });
17028
17029        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
17030        function renderScatterInEl(el,hOvr){
17031          if(!el||!SCAT_D||!SCAT_D.length)return;
17032          var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
17033          var W=Math.max(320,el.offsetWidth||480);
17034          var cW=W-PL-PR,cH=H-PT-PB;
17035          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
17036          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
17037          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
17038          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">';
17039          [0,0.25,0.5,0.75,1].forEach(function(t){
17040            var y=PT+cH*(1-t);
17041            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
17042            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>';
17043          });
17044          [0,0.25,0.5,0.75,1].forEach(function(t){
17045            var x=PL+cW*t;
17046            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
17047            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>';
17048          });
17049          SCAT_D.forEach(function(d,i){
17050            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
17051            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
17052            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"/>';
17053            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>';
17054          });
17055          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>';
17056          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>';
17057          s+='</svg>';
17058          el.innerHTML=s;
17059        }
17060        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
17061
17062        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
17063        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
17064        // the old vertical column layout on wide containers.
17065        function renderSemanticInEl(el,key,sh){
17066          if(!el||!SEM_D||!SEM_D.length)return;
17067          var n2=SEM_D.length||1;
17068          var LW=112,SH=sh||Math.max(180,n2*28+26);
17069          var svgW=Math.max(320,el.offsetWidth||480);
17070          var BW=Math.max(120,svgW-LW-80);
17071          var topPad=4,botPad=14;
17072          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
17073          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
17074          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
17075          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">';
17076          SEM_D.forEach(function(d,i){
17077            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
17078            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>';
17079            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"/>';
17080            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>';
17081          });
17082          s+='</svg>';
17083          el.innerHTML=s;
17084        }
17085        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
17086        var semSel=document.getElementById('r-semantic-metric');
17087        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
17088        var semExpand=document.getElementById('r-semantic-expand');
17089        if(semExpand){
17090          semExpand.addEventListener('click',function(){
17091            var key=semSel?semSel.value:'functions';
17092            var semLabels={'functions':'Functions','classes':'Classes / Types','variables':'Variables'};
17093            var semSubtitle=semLabels[key]||key;
17094            var n=SEM_D.length||1;
17095            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
17096            var modalH=Math.min(Math.max(360,n*38+60),maxH);
17097            var overlay=document.createElement('div');
17098            overlay.className='r-chart-modal-overlay';
17099            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>';
17100            document.body.appendChild(overlay);
17101            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
17102            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
17103            var modalEl=document.getElementById('r-sem-modal-chart');
17104            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
17105          });
17106        }
17107
17108        // ── Expand buttons: re-render charts at large size inside modal ──────────
17109        (function(){
17110          function makeExpandModal(title,mH,subtitle){
17111            var overlay=document.createElement('div');
17112            overlay.className='r-chart-modal-overlay';
17113            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
17114            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>';
17115            document.body.appendChild(overlay);
17116            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
17117            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
17118            return overlay.querySelector('.r-expand-modal-chart');
17119          }
17120          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
17121          var compExpandBtn=document.getElementById('r-composition-expand');
17122          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
17123            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
17124            var modeLabel=modeKey==='pct'?'Composition %':'Absolute Lines';
17125            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
17126            var wrap=makeExpandModal('Language Composition',mH,modeLabel);
17127            if(wrap)setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
17128          });}
17129          var scatExpandBtn=document.getElementById('r-scatter-expand');
17130          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
17131            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
17132            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
17133          });}
17134          var densExpandBtn=document.getElementById('r-density-expand');
17135          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
17136            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
17137            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
17138            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
17139          });}
17140          var avgExpandBtn=document.getElementById('r-avglines-expand');
17141          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
17142            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
17143            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
17144            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
17145          });}
17146          var subExpandBtn=document.getElementById('r-submodule-expand');
17147          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
17148            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
17149            var metricLabels={'code':'Code Lines','comment':'Comments','blank':'Blank Lines','physical':'Physical Lines','files':'Files'};
17150            var sortLabels={'desc':'Value ↓','asc':'Value ↑','name':'Name A→Z'};
17151            var subLabel=(metricLabels[key]||key)+' · '+(sortLabels[sort]||sort);
17152            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
17153            var wrap=makeExpandModal('Repository Overview',mH,subLabel);
17154            if(wrap)setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
17155          });}
17156        })();
17157
17158        // ── Comment Density: comments / (code + comments) per language ───────────
17159        function renderDensityInEl(el,shOvr){
17160          if(!el||!LANG_D||!LANG_D.length)return;
17161          var n=LANG_D.length||1;
17162          var LW=112,SH=shOvr||Math.max(180,n*28+26);
17163          var svgW=Math.max(320,el.offsetWidth||480);
17164          var BW=Math.max(120,svgW-LW-80);
17165          var topPad=4,botPad=26;
17166          var rowTotal=Math.floor((SH-topPad-botPad)/n);
17167          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
17168          var densities=LANG_D.map(function(d){
17169            var sig=(d.code||0)+(d.comments||0);
17170            return sig>0?(d.comments||0)/sig:0;
17171          });
17172          var maxDen=Math.max.apply(null,densities)||1;
17173          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">';
17174          LANG_D.forEach(function(d,i){
17175            var den=densities[i],bw=den/maxDen*BW;
17176            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
17177            var pct=Math.round(den*100);
17178            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>';
17179            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"/>';
17180            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17181            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>';
17182          });
17183          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>';
17184          s+='</svg>';
17185          el.innerHTML=s;
17186        }
17187        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
17188        renderDensity();
17189
17190        // ── Avg Lines per File: code / files per language ─────────────────────
17191        function renderAvgLinesInEl(el,shOvr){
17192          if(!el||!LANG_D||!LANG_D.length)return;
17193          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
17194          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
17195          var n=data.length||1;
17196          var LW=112,SH=shOvr||Math.max(180,n*28+26);
17197          var svgW=Math.max(320,el.offsetWidth||480);
17198          var BW=Math.max(120,svgW-LW-80);
17199          var topPad=4,botPad=26;
17200          var rowTotal=Math.floor((SH-topPad-botPad)/n);
17201          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
17202          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
17203          var maxAvg=Math.max.apply(null,avgs)||1;
17204          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">';
17205          data.forEach(function(d,i){
17206            var avg=avgs[i],bw=avg/maxAvg*BW;
17207            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
17208            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>';
17209            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"/>';
17210            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17211            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>';
17212          });
17213          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>';
17214          s+='</svg>';
17215          el.innerHTML=s;
17216        }
17217        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
17218        renderAvgLines();
17219
17220        // ── Repository Overview: overall row + per-submodule rows ────────────
17221        function renderSubmoduleInEl(el,key,sort,shOvr){
17222          if(!el)return;
17223          var overall={
17224            name:'Overall',
17225            code:LANG_D.reduce(function(s,d){return s+(d.code||0);},0),
17226            comment:LANG_D.reduce(function(s,d){return s+(d.comments||0);},0),
17227            blank:LANG_D.reduce(function(s,d){return s+(d.blanks||0);},0),
17228            physical:SCAT_D.reduce(function(s,d){return s+(d.physical||0);},0),
17229            files:LANG_D.reduce(function(s,d){return s+(d.files||0);},0),
17230            isOverall:true
17231          };
17232          var subs=SUB_D.slice();
17233          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
17234          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
17235          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
17236          var data=[overall].concat(subs);
17237          var rowH=32,bH=22,sepH=subs.length>0?14:0;
17238          var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
17239          var svgW=Math.max(320,el.offsetWidth||480);
17240          var LW=116,BW=Math.max(200,svgW-LW-54);
17241          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
17242          var OVERALL_COL='#6b7280';
17243          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">';
17244          var yOff=4;
17245          data.forEach(function(d,i){
17246            var v=d[key]||0,bw=v/maxV*BW,y=yOff;
17247            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
17248            var label=d.name||d.path||'?';
17249            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>';
17250            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
17251            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
17252            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>';
17253            yOff+=rowH;
17254            if(d.isOverall&&subs.length>0){
17255              yOff+=sepH;
17256            }
17257          });
17258          s+='</svg>';
17259          el.innerHTML=s;
17260        }
17261        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
17262        var subSel=document.getElementById('r-sub-metric');
17263        var sortSel=document.getElementById('r-sub-sort');
17264        renderSubmodule('code','desc');
17265        if(subSel){
17266          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
17267          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
17268        }
17269
17270        // Equalise heights within each chart row: if one chart in a grid row is taller
17271        // than its neighbour, re-render the shorter one at the taller height so bars fill
17272        // the available vertical space instead of leaving a gap.
17273        function syncRowHeights(){
17274          var avgEl=document.getElementById('r-avglines-chart');
17275          var subEl=document.getElementById('r-submodule-chart');
17276          if(avgEl&&subEl){
17277            var avgSvg=avgEl.querySelector('svg');
17278            var subSvg=subEl.querySelector('svg');
17279            if(avgSvg&&subSvg){
17280              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
17281              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
17282              var key=subSel?subSel.value||'code':'code';
17283              var sort=sortSel?sortSel.value:'desc';
17284              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
17285              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
17286            }
17287          }
17288          var semEl=document.getElementById('r-semantic-chart');
17289          var denEl=document.getElementById('r-density-chart');
17290          if(semEl&&denEl){
17291            var semSvg=semEl.querySelector('svg');
17292            var denSvg=denEl.querySelector('svg');
17293            if(semSvg&&denSvg){
17294              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
17295              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
17296              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
17297              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
17298            }
17299          }
17300        }
17301        syncRowHeights();
17302
17303        // Re-render all SVG charts when the window is resized so bars fill the card.
17304        var _rResizeTimer;
17305        window.addEventListener('resize',function(){
17306          clearTimeout(_rResizeTimer);
17307          _rResizeTimer=setTimeout(function(){
17308            var rcompBtn=document.querySelector('[data-rcomp].active');
17309            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
17310            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
17311            if(semSel)renderSemantic(semSel.value||'functions');
17312            renderDensity();
17313            renderAvgLines();
17314            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
17315            syncRowHeights();
17316          },120);
17317        });
17318      })();
17319
17320      (function randomizeWatermarks() {
17321        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
17322        if (!wms.length) return;
17323        var placed = [];
17324        function tooClose(top, left) {
17325          for (var i = 0; i < placed.length; i++) {
17326            var dt = Math.abs(placed[i][0] - top);
17327            var dl = Math.abs(placed[i][1] - left);
17328            if (dt < 20 && dl < 18) return true;
17329          }
17330          return false;
17331        }
17332        function pick(leftBand) {
17333          for (var attempt = 0; attempt < 50; attempt++) {
17334            var top = Math.random() * 85 + 5;
17335            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
17336            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
17337          }
17338          var top = Math.random() * 85 + 5;
17339          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
17340          placed.push([top, left]);
17341          return [top, left];
17342        }
17343        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
17344        var half = Math.floor(wms.length / 2);
17345        wms.forEach(function (img, i) {
17346          var pos = pick(i < half);
17347          var size = Math.floor(Math.random() * 100 + 160);
17348          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
17349          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
17350          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;
17351        });
17352      })();
17353
17354      (function spawnCodeParticles() {
17355        var container = document.getElementById('code-particles');
17356        if (!container) return;
17357        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'];
17358        for (var i = 0; i < 38; i++) {
17359          (function(idx) {
17360            var el = document.createElement('span');
17361            el.className = 'code-particle';
17362            el.textContent = snippets[idx % snippets.length];
17363            var left = Math.random() * 94 + 2;
17364            var top = Math.random() * 88 + 6;
17365            var dur = (Math.random() * 10 + 9).toFixed(1);
17366            var delay = (Math.random() * 18).toFixed(1);
17367            var rot = (Math.random() * 26 - 13).toFixed(1);
17368            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17369            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';
17370            container.appendChild(el);
17371          })(i);
17372        }
17373      })();
17374
17375      {% if pdf_generating %}
17376      // Poll for PDF readiness and swap the disabled button to a live link once done.
17377      (function() {
17378        var openBtn = document.getElementById('pdf-open-btn');
17379        var dlBtn = document.getElementById('pdf-download-btn');
17380        function checkPdf() {
17381          fetch('/api/runs/{{ run_id }}/pdf-status')
17382            .then(function(r) { return r.json(); })
17383            .then(function(d) {
17384              if (d.ready) {
17385                if (openBtn) {
17386                  var a = document.createElement('a');
17387                  a.className = 'button';
17388                  a.id = 'pdf-open-btn';
17389                  a.href = '/runs/pdf/{{ run_id }}';
17390                  a.target = '_blank';
17391                  a.rel = 'noopener';
17392                  a.textContent = 'Open PDF';
17393                  openBtn.replaceWith(a);
17394                }
17395                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
17396              } else {
17397                setTimeout(checkPdf, 3000);
17398              }
17399            })
17400            .catch(function() { setTimeout(checkPdf, 5000); });
17401        }
17402        setTimeout(checkPdf, 3000);
17403      })();
17404      {% endif %}
17405
17406    })();
17407  </script>
17408  <script nonce="{{ csp_nonce }}">
17409  (function(){
17410    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'}];
17411    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);});}
17412    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17413    function init(){
17414      var btn=document.getElementById('settings-btn');if(!btn)return;
17415      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17416      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>';
17417      document.body.appendChild(m);
17418      var g=document.getElementById('scheme-grid');
17419      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);});
17420      var cl=document.getElementById('settings-close');
17421      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);
17422      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');});
17423      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17424      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17425    }
17426    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17427  }());
17428  </script>
17429  <footer class="site-footer">
17430    local code analysis - metrics, history and reports
17431    &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>
17432    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17433    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17434    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17435    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17436  </footer>
17437  {% if confluence_configured %}
17438  <script nonce="{{ csp_nonce }}">
17439  (function() {
17440    var postBtn = document.getElementById('postConfluenceBtn');
17441    var copyBtn = document.getElementById('copyWikiBtn');
17442    var modal   = document.getElementById('confluenceModal');
17443    if (!postBtn || !modal) return;
17444
17445    postBtn.addEventListener('click', function() {
17446      document.getElementById('confStatus').style.display = 'none';
17447      modal.style.display = 'flex';
17448    });
17449    document.getElementById('confCancelBtn').addEventListener('click', function() {
17450      modal.style.display = 'none';
17451    });
17452    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17453
17454    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
17455      var btn = this;
17456      btn.disabled = true;
17457      var status = document.getElementById('confStatus');
17458      status.style.display = 'block';
17459      status.style.background = '#dbeafe';
17460      status.style.color = '#1e40af';
17461      status.textContent = 'Posting to Confluence…';
17462      var resp = await fetch('/api/confluence/post', {
17463        method: 'POST',
17464        headers: { 'Content-Type': 'application/json' },
17465        body: JSON.stringify({
17466          run_id: '{{ run_id }}',
17467          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
17468          report_url: document.getElementById('confReportUrl').value.trim() || null
17469        })
17470      });
17471      var data = await resp.json();
17472      if (data.ok) {
17473        status.style.background = '#dcfce7'; status.style.color = '#166534';
17474        status.textContent = 'Posted! Page ID: ' + data.page_id;
17475      } else {
17476        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17477        status.textContent = 'Error: ' + (data.error || 'Unknown error');
17478      }
17479      btn.disabled = false;
17480    });
17481
17482    if (copyBtn) {
17483      copyBtn.addEventListener('click', async function() {
17484        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
17485        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
17486        var text = await resp.text();
17487        try {
17488          await navigator.clipboard.writeText(text);
17489          var orig = copyBtn.textContent;
17490          copyBtn.textContent = 'Copied!';
17491          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
17492        } catch(e) {
17493          alert('Clipboard write failed — check browser permissions.');
17494        }
17495      });
17496    }
17497  })();
17498  </script>
17499  {% endif %}
17500  <script nonce="{{ csp_nonce }}">
17501  (function() {
17502    var deleteBtn = document.getElementById('delete-run-btn');
17503    var modal     = document.getElementById('delete-run-modal');
17504    var cancelBtn = document.getElementById('delete-run-cancel');
17505    var confirmBtn= document.getElementById('delete-run-confirm');
17506    if (!deleteBtn || !modal) return;
17507    deleteBtn.addEventListener('click', function() {
17508      document.getElementById('delete-run-status').style.display = 'none';
17509      modal.style.display = 'flex';
17510    });
17511    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
17512    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17513    confirmBtn.addEventListener('click', async function() {
17514      confirmBtn.disabled = true;
17515      cancelBtn.disabled = true;
17516      var status = document.getElementById('delete-run-status');
17517      status.style.display = 'block';
17518      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
17519      status.textContent = 'Deleting…';
17520      try {
17521        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
17522        if (resp.status === 204 || resp.ok) {
17523          status.style.background = '#dcfce7'; status.style.color = '#166534';
17524          status.textContent = 'Deleted. Redirecting…';
17525          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
17526        } else {
17527          var d = await resp.json().catch(function(){return {};});
17528          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17529          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
17530          confirmBtn.disabled = false;
17531          cancelBtn.disabled = false;
17532        }
17533      } catch (e) {
17534        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17535        status.textContent = 'Network error: ' + String(e);
17536        confirmBtn.disabled = false;
17537        cancelBtn.disabled = false;
17538      }
17539    });
17540  })();
17541  </script>
17542  <script nonce="{{ csp_nonce }}">(function(){
17543    var bundleBtn = document.getElementById('download-bundle-btn');
17544    if (bundleBtn) {
17545      bundleBtn.addEventListener('click', function() {
17546        bundleBtn.disabled = true;
17547        var orig = bundleBtn.textContent;
17548        bundleBtn.textContent = 'Preparing…';
17549        fetch('/api/runs/{{ run_id }}/bundle')
17550          .then(function(r) {
17551            if (!r.ok) throw new Error('HTTP ' + r.status);
17552            return r.blob();
17553          })
17554          .then(function(blob) {
17555            var url = URL.createObjectURL(blob);
17556            var a = document.createElement('a');
17557            a.href = url;
17558            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
17559            document.body.appendChild(a);
17560            a.click();
17561            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
17562            bundleBtn.disabled = false;
17563            bundleBtn.textContent = orig;
17564          })
17565          .catch(function(e) {
17566            bundleBtn.disabled = false;
17567            bundleBtn.textContent = orig;
17568            alert('Bundle download failed: ' + String(e));
17569          });
17570      });
17571    }
17572  })();</script>
17573  <script nonce="{{ csp_nonce }}">(function(){
17574    var dot=document.getElementById('status-dot');
17575    var pingEl=document.getElementById('server-ping-ms');
17576    var tipEl=document.getElementById('server-tip-ping');
17577    var fm=document.getElementById('footer-mode');
17578    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)';}}
17579    function doPing(){
17580      var t0=performance.now();
17581      fetch('/healthz',{cache:'no-store'})
17582        .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);})
17583        .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)';}});
17584    }
17585    doPing();
17586    setInterval(doPing,5000);
17587    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');}
17588  })();</script>
17589  {% if let Some(banner) = report_header_footer %}
17590  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
17591  {% endif %}
17592</body>
17593</html>
17594"##,
17595    ext = "html"
17596)]
17597// Template structs need many bool fields to pass Askama rendering flags.
17598#[allow(clippy::struct_excessive_bools)]
17599struct ResultTemplate {
17600    version: &'static str,
17601    report_title: String,
17602    project_path: String,
17603    output_dir: String,
17604    run_id: String,
17605    files_analyzed: u64,
17606    files_skipped: u64,
17607    physical_lines: u64,
17608    code_lines: u64,
17609    comment_lines: u64,
17610    blank_lines: u64,
17611    mixed_lines: u64,
17612    functions: u64,
17613    classes: u64,
17614    variables: u64,
17615    imports: u64,
17616    html_url: Option<String>,
17617    pdf_url: Option<String>,
17618    json_url: Option<String>,
17619    html_download_url: Option<String>,
17620    pdf_download_url: Option<String>,
17621    json_download_url: Option<String>,
17622    html_path: Option<String>,
17623    json_path: Option<String>,
17624    prev_run_id: Option<String>,
17625    prev_run_timestamp: Option<String>,
17626    prev_run_code_lines: Option<u64>,
17627    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
17628    prev_fa_str: String,
17629    prev_fs_str: String,
17630    prev_pl_str: String,
17631    prev_cl_str: String,
17632    prev_cml_str: String,
17633    prev_bl_str: String,
17634    // Signed change column for main metrics
17635    delta_fa_str: String,
17636    delta_fa_class: String,
17637    delta_fs_str: String,
17638    delta_fs_class: String,
17639    delta_pl_str: String,
17640    delta_pl_class: String,
17641    delta_cl_str: String,
17642    delta_cl_class: String,
17643    delta_cml_str: String,
17644    delta_cml_class: String,
17645    delta_bl_str: String,
17646    delta_bl_class: String,
17647    // delta vs previous scan
17648    delta_lines_added: Option<i64>,
17649    delta_lines_removed: Option<i64>,
17650    delta_lines_net_str: String,
17651    delta_lines_net_class: String,
17652    delta_files_added: Option<usize>,
17653    delta_files_removed: Option<usize>,
17654    delta_files_modified: Option<usize>,
17655    delta_files_unchanged: Option<usize>,
17656    delta_unmodified_lines: Option<u64>,
17657    // git context
17658    git_branch: Option<String>,
17659    git_commit: Option<String>,
17660    git_commit_long: Option<String>,
17661    git_author: Option<String>,
17662    git_commit_url: Option<String>,
17663    // scan metadata for hero section
17664    scan_performed_by: String,
17665    scan_time_display: String,
17666    os_display: String,
17667    test_count: u64,
17668    // history
17669    prev_scan_count: usize,
17670    current_scan_number: usize,
17671    // submodule breakdown (empty when not requested)
17672    submodule_rows: Vec<SubmoduleRow>,
17673    scan_config_url: String,
17674    lang_chart_json: String,
17675    // Askama reads these via proc-macro expansion; clippy can't trace through it.
17676    #[allow(dead_code)]
17677    scatter_chart_json: String,
17678    #[allow(dead_code)]
17679    semantic_chart_json: String,
17680    #[allow(dead_code)]
17681    submodule_chart_json: String,
17682    #[allow(dead_code)]
17683    has_submodule_data: bool,
17684    #[allow(dead_code)]
17685    has_semantic_data: bool,
17686    pdf_generating: bool,
17687    csp_nonce: String,
17688    /// Whether Confluence integration is configured — shows Post button when true.
17689    confluence_configured: bool,
17690    server_mode: bool,
17691    /// Header/footer identification banner, mirrored from the HTML/PDF report.
17692    report_header_footer: Option<String>,
17693    run_id_short: String,
17694}
17695
17696#[derive(Template)]
17697#[template(
17698    source = r##"
17699<!doctype html>
17700<html lang="en">
17701<head>
17702  <meta charset="utf-8">
17703  <meta name="viewport" content="width=device-width, initial-scale=1">
17704  <title>OxideSLOC | Analyzing…</title>
17705  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17706  <style nonce="{{ csp_nonce }}">
17707    :root {
17708      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17709      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17710      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17711      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17712    }
17713    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17714    *{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;}
17715    .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);}
17716    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17717    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
17718    .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));}
17719    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17720    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17721    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17722    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17723    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17724    @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; } }
17725    .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;}
17726    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17727    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17728    .page-body{padding:32px 24px 36px;}
17729    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
17730    .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;}
17731    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
17732    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
17733    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
17734    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
17735    .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;}
17736    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
17737    .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;}
17738    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
17739    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
17740    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
17741    .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;}
17742    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
17743    .hidden{display:none!important;}
17744    .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;}
17745    .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;}
17746    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
17747    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
17748    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
17749    .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);}
17750    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
17751    .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;}
17752    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
17753    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17754    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17755    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
17756    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17757    .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;}
17758    @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));}}
17759    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17760    .site-footer a{color:var(--muted);}
17761    .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;}
17762    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
17763    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
17764    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
17765  </style>
17766</head>
17767<body>
17768  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17775  </div>
17776  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17777  <nav class="top-nav">
17778    <div class="top-nav-inner">
17779      <a href="/" class="brand">
17780        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
17781        <div class="brand-copy">
17782          <h1 class="brand-title">OxideSLOC</h1>
17783          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17784        </div>
17785      </a>
17786      <div class="nav-right">
17787        <a class="nav-pill" href="/">Home</a>
17788        <div class="nav-dropdown">
17789          <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>
17790          <div class="nav-dropdown-menu">
17791            <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>
17792          </div>
17793        </div>
17794        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17795        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17796        <div class="nav-dropdown">
17797          <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>
17798          <div class="nav-dropdown-menu">
17799            <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>
17800          </div>
17801        </div>
17802        <div class="server-status-wrap" id="server-status-wrap">
17803          <div class="nav-pill server-online-pill" id="server-status-pill">
17804            <span class="status-dot" id="status-dot"></span>
17805            <span id="server-status-label">Server</span>
17806            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17807          </div>
17808          <div class="server-status-tip">
17809            OxideSLOC is running — accessible on your network.
17810            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17811          </div>
17812        </div>
17813        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17814          <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>
17815        </button>
17816        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17817          <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>
17818          <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>
17819        </button>
17820      </div>
17821    </div>
17822  </nav>
17823  <div class="page-body">
17824    <div class="wait-panel">
17825      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
17826      <h2 class="wait-title">Analyzing your project…</h2>
17827      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
17828      <div class="path-block">{{ project_path }}</div>
17829      <div class="metrics-row">
17830        <div class="metric-card">
17831          <div class="metric-label">Elapsed</div>
17832          <div class="metric-value" id="elapsed">0s</div>
17833        </div>
17834        <div class="metric-card">
17835          <div class="metric-label">Phase</div>
17836          <div class="metric-value" id="phase">Starting</div>
17837        </div>
17838        <div class="metric-card hidden" id="files-card">
17839          <div class="metric-label">Files</div>
17840          <div class="metric-value" id="files-progress">0</div>
17841        </div>
17842      </div>
17843      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
17844      <div class="warn-slow hidden" id="warn-slow">
17845        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.
17846      </div>
17847      <div class="err-panel hidden" id="err-panel">
17848        <strong>Analysis failed</strong>
17849        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
17850      </div>
17851      <div class="actions hidden" id="actions">
17852        <a href="/scan" class="btn-primary">Try Again</a>
17853        <a href="/view-reports" class="btn-outline">View Reports</a>
17854      </div>
17855    </div>
17856  </div>
17857  <script nonce="{{ csp_nonce }}">
17858    (function() {
17859      var WAIT_ID = {{ wait_id_json|safe }};
17860      var startTime = Date.now();
17861      var pollInterval = 1500;
17862      var retries = 0;
17863      var maxRetries = 5;
17864      var warnShown = false;
17865
17866      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();}
17867
17868      function elapsed() {
17869        return Math.floor((Date.now() - startTime) / 1000);
17870      }
17871
17872      function updateElapsed() {
17873        var s = elapsed();
17874        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
17875      }
17876
17877      function setPhase(txt) {
17878        document.getElementById('phase').textContent = txt;
17879      }
17880
17881      var elapsedTimer = setInterval(updateElapsed, 1000);
17882
17883      function poll() {
17884        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
17885          .then(function(r) {
17886            if (!r.ok) throw new Error('HTTP ' + r.status);
17887            return r.json();
17888          })
17889          .then(function(data) {
17890            retries = 0;
17891            if (data.state === 'complete') {
17892              clearInterval(elapsedTimer);
17893              setPhase('Done');
17894              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
17895            } else if (data.state === 'failed') {
17896              clearInterval(elapsedTimer);
17897              setPhase('Failed');
17898              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
17899              document.getElementById('err-panel').classList.remove('hidden');
17900              document.getElementById('actions').classList.remove('hidden');
17901            } else {
17902              // still running
17903              var s = elapsed();
17904              if (s > 90 && !warnShown) {
17905                warnShown = true;
17906                document.getElementById('warn-slow').classList.remove('hidden');
17907              }
17908              setPhase(data.phase || 'Running');
17909              var fd = data.files_done || 0, ft = data.files_total || 0;
17910              if (ft > 0) {
17911                var card = document.getElementById('files-card');
17912                if (card) card.classList.remove('hidden');
17913                var fp = document.getElementById('files-progress');
17914                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
17915              }
17916              setTimeout(poll, pollInterval);
17917            }
17918          })
17919          .catch(function(err) {
17920            retries++;
17921            if (retries >= maxRetries) {
17922              clearInterval(elapsedTimer);
17923              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
17924              document.getElementById('err-panel').classList.remove('hidden');
17925              document.getElementById('actions').classList.remove('hidden');
17926            } else {
17927              // exponential back-off capped at 8s
17928              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
17929            }
17930          });
17931      }
17932
17933      setTimeout(poll, pollInterval);
17934    })();
17935  </script>
17936  <footer class="site-footer">
17937    local code analysis - metrics, history and reports
17938    &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>
17939    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17940    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17941    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17942    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17943  </footer>
17944  <script nonce="{{ csp_nonce }}">
17945    (function(){
17946      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
17947      if(s==="dark")b.classList.add("dark-theme");
17948      var tt=document.getElementById("theme-toggle");
17949      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
17950    })();
17951    (function spawnCodeParticles(){
17952      var c=document.getElementById('code-particles');if(!c)return;
17953      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'];
17954      for(var i=0;i<32;i++){(function(idx){
17955        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
17956        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
17957        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
17958        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
17959        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
17960        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
17961        c.appendChild(el);
17962      })(i);}
17963    })();
17964    (function randomizeWatermarks(){
17965      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17966      var placed=[];
17967      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;}
17968      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];}
17969      var half=Math.floor(wms.length/2);
17970      wms.forEach(function(img,i){
17971        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
17972        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
17973        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
17974        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
17975        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
17976        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
17977      });
17978    })();
17979  </script>
17980  <script nonce="{{ csp_nonce }}">
17981  (function(){
17982    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'}];
17983    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);});}
17984    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17985    function init(){
17986      var btn=document.getElementById('settings-btn');if(!btn)return;
17987      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17988      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>';
17989      document.body.appendChild(m);
17990      var g=document.getElementById('scheme-grid');
17991      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);});
17992      var cl=document.getElementById('settings-close');
17993      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);
17994      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');});
17995      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17996      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17997    }
17998    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17999  }());
18000  </script>
18001  <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>
18002</body>
18003</html>
18004"##,
18005    ext = "html"
18006)]
18007struct ScanWaitTemplate {
18008    version: &'static str,
18009    wait_id_json: String,
18010    project_path: String,
18011    csp_nonce: String,
18012}
18013
18014#[derive(Template)]
18015#[template(
18016    source = r##"
18017<!doctype html>
18018<html lang="en">
18019<head>
18020  <meta charset="utf-8">
18021  <meta name="viewport" content="width=device-width, initial-scale=1">
18022  <title>OxideSLOC | Error</title>
18023  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18024  <style nonce="{{ csp_nonce }}">
18025    :root {
18026      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18027      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18028      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
18029      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18030    }
18031    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18032    *{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;}
18033    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18034    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18035    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
18036    .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);}
18037    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18038    .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));}
18039    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18040    .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;}
18041    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18042    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18043    @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; } }
18044    .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;}
18045    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18046    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18047    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18048    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18049    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18050    .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;}
18051    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18052    .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);}
18053    .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;}
18054    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18055    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18056    .settings-modal-body{padding:14px 16px 16px;}
18057    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18058    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18059    .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;}
18060    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18061    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18062    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18063    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18064    .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;}
18065    .tz-select:focus{border-color:var(--oxide);}
18066    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
18067    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
18068    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
18069    .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;}
18070    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
18071    .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);}
18072    .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;}
18073    .btn-secondary:hover{background:var(--line);}
18074    .bug-report-wrap{margin-top:22px;border-top:1px solid var(--line);padding-top:16px;}
18075    .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;}
18076    .bug-report-wrap summary::-webkit-details-marker{display:none;}
18077    .bug-report-arrow{display:inline-block;font-size:9px;transition:transform .15s ease;}
18078    .bug-report-wrap[open] .bug-report-arrow{transform:rotate(90deg);}
18079    .bug-report-wrap summary:hover{color:var(--text);}
18080    .bug-report-body{margin-top:12px;display:flex;flex-direction:column;gap:10px;}
18081    .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;}
18082    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
18083    .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;}
18084    .btn-sm:hover{background:var(--line);}
18085    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
18086    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
18087    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
18088    .bug-report-hint a:hover{text-decoration:underline;}
18089    .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;}
18090    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
18091    .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;}
18092    .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;}
18093    .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;}
18094    @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));}}
18095    .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;}
18096  </style>
18097</head>
18098<body>
18099  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18106  </div>
18107  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18108  <div class="top-nav">
18109    <div class="top-nav-inner">
18110      <a class="brand" href="/">
18111        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18112        <div class="brand-copy">
18113          <div class="brand-title">OxideSLOC</div>
18114          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18115        </div>
18116      </a>
18117      <div class="nav-right">
18118        <a class="nav-pill" href="/">Home</a>
18119        <div class="nav-dropdown">
18120          <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>
18121          <div class="nav-dropdown-menu">
18122            <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>
18123          </div>
18124        </div>
18125        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18126        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18127        <div class="nav-dropdown">
18128          <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>
18129          <div class="nav-dropdown-menu">
18130            <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>
18131          </div>
18132        </div>
18133        <div class="server-status-wrap" id="server-status-wrap">
18134          <div class="nav-pill server-online-pill" id="server-status-pill">
18135            <span class="status-dot" id="status-dot"></span>
18136            <span id="server-status-label">Server</span>
18137            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18138          </div>
18139          <div class="server-status-tip">
18140            OxideSLOC is running — accessible on your network.
18141            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18142          </div>
18143        </div>
18144        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18145          <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>
18146        </button>
18147        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18148          <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>
18149          <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>
18150        </button>
18151      </div>
18152    </div>
18153  </div>
18154
18155  <div class="page">
18156    <div class="panel">
18157      <h1>Error</h1>
18158      <div class="error-box" id="error-msg-text">{{ message }}</div>
18159      <div id="br-meta" hidden
18160        data-version="{{ version }}"
18161        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
18162        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
18163      <div class="actions">
18164        <a class="btn-primary" href="/scan">Back to setup</a>
18165        {% if let Some(report_url) = last_report_url %}
18166        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
18167        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
18168        {% else %}
18169        <a class="btn-secondary" href="/view-reports">View Reports</a>
18170        {% endif %}
18171      </div>
18172      <details class="bug-report-wrap" id="bug-report-wrap">
18173        <summary><span class="bug-report-arrow">&#9658;</span>&nbsp;Generate bug report</summary>
18174        <div class="bug-report-body">
18175          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
18176          <div class="bug-report-btns">
18177            <button type="button" class="btn-sm" id="bug-report-copy">
18178              <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>
18179              Copy to clipboard
18180            </button>
18181            <a class="btn-sm" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer">
18182              <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>
18183              Open GitHub Issue
18184            </a>
18185          </div>
18186          <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>
18187        </div>
18188      </details>
18189    </div>
18190  </div>
18191  <footer class="site-footer">
18192    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
18193    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18194    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18195    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18196    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
18197  </footer>
18198  <script nonce="{{ csp_nonce }}">(function(){
18199    var meta=document.getElementById('br-meta');
18200    var pre=document.getElementById('bug-report-pre');
18201    var copyBtn=document.getElementById('bug-report-copy');
18202    if(!meta||!pre)return;
18203    var ver=meta.getAttribute('data-version')||'';
18204    var runId=meta.getAttribute('data-run-id')||'';
18205    var code=meta.getAttribute('data-error-code')||'';
18206    var msgEl=document.getElementById('error-msg-text');
18207    var msg=msgEl?msgEl.textContent.trim():'';
18208    function getBrowser(){
18209      var ua=navigator.userAgent;
18210      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
18211      if(!m)return 'Unknown browser';
18212      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
18213      return n+' '+m[2];
18214    }
18215    var lines=['oxide-sloc Bug Report','==============================',''];
18216    lines.push('App version:  v'+ver);
18217    if(code)lines.push('HTTP status:  '+code);
18218    if(runId)lines.push('Run ID:       '+runId);
18219    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
18220    lines.push('Timestamp:    '+new Date().toISOString());
18221    lines.push('Browser:      '+getBrowser());
18222    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
18223    lines.push('');
18224    lines.push('Error message:');
18225    lines.push(msg);
18226    lines.push('');
18227    lines.push('Steps to reproduce:');
18228    lines.push('  1. ');
18229    lines.push('');
18230    lines.push('Expected behavior:');
18231    lines.push('  ');
18232    pre.textContent=lines.join('\n');
18233    if(copyBtn){
18234      copyBtn.addEventListener('click',function(){
18235        var txt=pre.textContent;
18236        if(navigator.clipboard&&navigator.clipboard.writeText){
18237          navigator.clipboard.writeText(txt).then(function(){
18238            copyBtn.textContent='[OK] Copied!';
18239            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);
18240          });
18241        }else{
18242          var ta=document.createElement('textarea');
18243          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
18244          document.body.appendChild(ta);ta.select();
18245          try{document.execCommand('copy');copyBtn.textContent='[OK] Copied!';}catch(e){}
18246          document.body.removeChild(ta);
18247        }
18248      });
18249    }
18250  })();</script>
18251  <script nonce="{{ csp_nonce }}">
18252    (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");});})();
18253    (function spawnCodeParticles() {
18254      var container = document.getElementById('code-particles');
18255      if (!container) return;
18256      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'];
18257      for (var i = 0; i < 38; i++) {
18258        (function(idx) {
18259          var el = document.createElement('span');
18260          el.className = 'code-particle';
18261          el.textContent = snippets[idx % snippets.length];
18262          var left = Math.random() * 94 + 2;
18263          var top = Math.random() * 88 + 6;
18264          var dur = (Math.random() * 10 + 9).toFixed(1);
18265          var delay = (Math.random() * 18).toFixed(1);
18266          var rot = (Math.random() * 26 - 13).toFixed(1);
18267          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18268          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';
18269          container.appendChild(el);
18270        })(i);
18271      }
18272    })();
18273    (function randomizeWatermarks() {
18274      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18275      var placed = [];
18276      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; }
18277      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]; }
18278      var half = Math.floor(wms.length/2);
18279      wms.forEach(function(img, i) {
18280        var pos = pick(i < half);
18281        var w = Math.floor(Math.random()*60+80);
18282        var rot = (Math.random()*40-20).toFixed(1);
18283        var op = (Math.random()*0.08+0.05).toFixed(2);
18284        var animDur = (Math.random()*6+5).toFixed(1);
18285        var animDelay = (Math.random()*10).toFixed(1);
18286        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';
18287      });
18288    })();
18289  </script>
18290  <script nonce="{{ csp_nonce }}">
18291  (function(){
18292    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'}];
18293    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);});}
18294    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18295    function init(){
18296      var btn=document.getElementById('settings-btn');if(!btn)return;
18297      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18298      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>';
18299      document.body.appendChild(m);
18300      var g=document.getElementById('scheme-grid');
18301      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);});
18302      var cl=document.getElementById('settings-close');
18303      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);
18304      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');});
18305      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18306      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18307    }
18308    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18309  }());
18310  </script>
18311  <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>
18312</body>
18313</html>
18314"##,
18315    ext = "html"
18316)]
18317struct ErrorTemplate {
18318    message: String,
18319    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
18320    last_report_url: Option<String>,
18321    /// Label for the secondary action button; defaults to "View last report" when None.
18322    last_report_label: Option<String>,
18323    /// Run ID to surface in the bug report; `None` when not applicable.
18324    run_id: Option<String>,
18325    /// HTTP status code to surface in the bug report; `None` when unknown.
18326    error_code: Option<u16>,
18327    csp_nonce: String,
18328    version: &'static str,
18329}
18330
18331// ── RelocateScanTemplate ──────────────────────────────────────────────────────
18332
18333#[derive(Template)]
18334#[template(
18335    source = r##"
18336<!doctype html>
18337<html lang="en">
18338<head>
18339  <meta charset="utf-8">
18340  <meta name="viewport" content="width=device-width, initial-scale=1">
18341  <title>OxideSLOC | Locate Scan Files</title>
18342  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18343  <style nonce="{{ csp_nonce }}">
18344    :root {
18345      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18346      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18347      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
18348      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18349    }
18350    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18351    *{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;}
18352    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18353    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18354    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
18355    .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);}
18356    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18357    .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));}
18358    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18359    .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;}
18360    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18361    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
18362    @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;}}
18363    .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;}
18364    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18365    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18366    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18367    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18368    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18369    .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;}
18370    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18371    .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);}
18372    .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;}
18373    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18374    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18375    .settings-modal-body{padding:14px 16px 16px;}
18376    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18377    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18378    .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;}
18379    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18380    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18381    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18382    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18383    .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;}
18384    .tz-select:focus{border-color:var(--oxide);}
18385    .page{max-width:860px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
18386    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
18387    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
18388    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
18389    .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;}
18390    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
18391    .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;}
18392    .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;}
18393    .btn-secondary:hover{background:var(--line);}
18394    .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;}
18395    .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;}
18396    .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;}
18397    @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));}}
18398    .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;}
18399    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
18400    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
18401    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
18402    .relocate-row{display:flex;gap:8px;align-items:stretch;}
18403    .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;}
18404    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
18405    body.dark-theme .relocate-input{background:var(--surface-2);}
18406  </style>
18407</head>
18408<body>
18409  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18416  </div>
18417  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18418  <div class="top-nav">
18419    <div class="top-nav-inner">
18420      <a class="brand" href="/">
18421        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18422        <div class="brand-copy">
18423          <div class="brand-title">OxideSLOC</div>
18424          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18425        </div>
18426      </a>
18427      <div class="nav-right">
18428        <a class="nav-pill" href="/">Home</a>
18429        <div class="nav-dropdown">
18430          <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>
18431          <div class="nav-dropdown-menu">
18432            <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>
18433          </div>
18434        </div>
18435        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
18436        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18437        <div class="nav-dropdown">
18438          <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>
18439          <div class="nav-dropdown-menu">
18440            <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>
18441          </div>
18442        </div>
18443        <div class="server-status-wrap" id="server-status-wrap">
18444          <div class="nav-pill server-online-pill" id="server-status-pill">
18445            <span class="status-dot" id="status-dot"></span>
18446            <span id="server-status-label">Server</span>
18447            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18448          </div>
18449          <div class="server-status-tip">
18450            OxideSLOC is running — accessible on your network.
18451            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18452          </div>
18453        </div>
18454        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18455          <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>
18456        </button>
18457        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18458          <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>
18459          <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>
18460        </button>
18461      </div>
18462    </div>
18463  </div>
18464
18465  <div class="page">
18466    <div class="panel">
18467      <h1>Scan Files Moved</h1>
18468      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
18469      <div class="error-box">{{ message }}</div>
18470      <div class="relocate-section">
18471        <h2>Locate Scan Output</h2>
18472        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
18473        <form method="post" action="/relocate-scan">
18474          <input type="hidden" name="run_id" value="{{ run_id }}">
18475          <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
18476          <div class="relocate-row">
18477            <input type="text" id="relocate-folder" name="folder_path"
18478                   value="{{ folder_hint }}"
18479                   placeholder="Path to folder containing scan output..."
18480                   class="relocate-input" autocomplete="off" spellcheck="false">
18481            {% if !server_mode %}
18482            <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
18483            {% endif %}
18484          </div>
18485          <div style="margin-top:12px;">
18486            <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
18487          </div>
18488        </form>
18489      </div>
18490      <div class="actions">
18491        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
18492        <a class="btn-secondary" href="/view-reports">View Reports</a>
18493      </div>
18494    </div>
18495  </div>
18496  <script nonce="{{ csp_nonce }}">
18497    (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");});})();
18498    (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);}})();
18499    (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';});})();
18500  </script>
18501  <script nonce="{{ csp_nonce }}">
18502  (function(){
18503    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'}];
18504    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);});}
18505    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18506    function init(){
18507      var btn=document.getElementById('settings-btn');if(!btn)return;
18508      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18509      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>';
18510      document.body.appendChild(m);
18511      var g=document.getElementById('scheme-grid');
18512      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);});
18513      var cl=document.getElementById('settings-close');
18514      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);
18515      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');});
18516      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18517      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18518    }
18519    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18520  }());
18521  (function(){
18522    var btn=document.getElementById('browse-relocate-btn');
18523    if(!btn)return;
18524    btn.addEventListener('click',function(){
18525      btn.disabled=true;btn.textContent='...';
18526      var inp=document.getElementById('relocate-folder');
18527      var hint=inp?inp.value:'';
18528      fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
18529        .then(function(r){return r.ok?r.json():{cancelled:true};})
18530        .then(function(d){
18531          btn.disabled=false;btn.textContent='Browse…';
18532          if(d&&d.selected_path&&inp)inp.value=d.selected_path;
18533        })
18534        .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
18535    });
18536  }());
18537  </script>
18538  <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>
18539</body>
18540</html>
18541"##,
18542    ext = "html"
18543)]
18544struct RelocateScanTemplate {
18545    message: String,
18546    run_id: String,
18547    folder_hint: String,
18548    redirect_url: String,
18549    server_mode: bool,
18550    csp_nonce: String,
18551    version: &'static str,
18552}
18553
18554// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
18555
18556#[derive(Template)]
18557#[template(
18558    source = r##"
18559<!doctype html>
18560<html lang="en">
18561<head>
18562  <meta charset="utf-8">
18563  <meta name="viewport" content="width=device-width, initial-scale=1">
18564  <title>OxideSLOC | View Reports</title>
18565  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18566  <style nonce="{{ csp_nonce }}">
18567    :root {
18568      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18569      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18570      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18571      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18572      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
18573    }
18574    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; }
18575    *{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;}
18576    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18577    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18578    .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);}
18579    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18580    .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));}
18581    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18582    .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;}
18583    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18584    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18585    @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; } }
18586    .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;}
18587    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18588    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18589    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18590    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18591    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18592    .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;}
18593    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18594    .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);}
18595    .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;}
18596    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18597    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18598    .settings-modal-body{padding:14px 16px 16px;}
18599    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18600    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18601    .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;}
18602    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18603    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18604    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18605    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18606    .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;}
18607    .tz-select:focus{border-color:var(--oxide);}
18608    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18609    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18610    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18611    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18612    .panel-meta{font-size:13px;color:var(--muted);}
18613    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18614    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18615    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18616    .per-page-label{font-size:13px;color:var(--muted);}
18617    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;}
18618    .filter-input{min-width:180px;cursor:text;}
18619    .table-wrap{width:100%;overflow-x:auto;}
18620    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
18621    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;}
18622    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18623    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18624    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18625    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18626    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18627    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18628    tr:last-child td{border-bottom:none;}
18629    tr:hover td{background:var(--surface-2);}
18630    .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);}
18631    .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);}
18632    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18633    .metric-num{font-weight:700;color:var(--text);}
18634    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18635    .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;}
18636    .btn:hover{background:var(--line);}
18637    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18638    .btn.primary:hover{opacity:.9;}
18639    .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;}
18640    .btn-back:hover{background:var(--line);}
18641    .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;}
18642    .export-btn:hover{background:var(--line);}
18643    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
18644    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
18645    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
18646    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18647    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18648    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18649    .pagination-info{font-size:13px;color:var(--muted);}
18650    .pagination-btns{display:flex;gap:6px;}
18651    .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;}
18652    .pg-btn:hover:not(:disabled){background:var(--line);}
18653    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18654    .pg-btn:disabled{opacity:.35;cursor:default;}
18655    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18656    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18657    .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;}
18658    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18659    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18660    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18661    .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);}
18662    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18663    .stat-chip:hover .stat-chip-tip{opacity:1;}
18664    .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;}
18665    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18666    .site-footer a{color:var(--muted);}
18667    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18668    .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%;}
18669    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
18670    .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;}
18671    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18672    .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;}
18673    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18674    .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;}
18675    .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;}
18676    .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;}
18677    @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));}}
18678    .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;}
18679    .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;}
18680    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18681    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18682    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18683    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18684    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18685    .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;}
18686    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18687    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18688    .watched-chip-rm:hover{color:var(--oxide);}
18689    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18690    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18691    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18692    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18693    .rpt-btn{min-width:58px;justify-content:center;}
18694    .flex-row{display:flex;align-items:center;gap:8px;}
18695    .report-cell{overflow:visible;white-space:normal;}
18696    #history-table col:nth-child(1){width:185px;}
18697    #history-table col:nth-child(2){width:220px;}
18698    #history-table col:nth-child(3){width:100px;}
18699    #history-table col:nth-child(4){width:72px;}
18700    #history-table col:nth-child(5){width:82px;}
18701    #history-table col:nth-child(6){width:82px;}
18702    #history-table col:nth-child(7){width:65px;}
18703    #history-table col:nth-child(8){width:90px;}
18704    #history-table col:nth-child(9){width:85px;}
18705    #history-table col:nth-child(10){width:115px;}
18706    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
18707    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
18708    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
18709    .submod-details summary::-webkit-details-marker{display:none;}
18710.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
18711    .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;}
18712    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
18713    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
18714  </style>
18715</head>
18716<body>
18717  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18724  </div>
18725  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18726  <div class="top-nav">
18727    <div class="top-nav-inner">
18728      <a class="brand" href="/">
18729        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18730        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
18731      </a>
18732      <div class="nav-right">
18733        <a class="nav-pill" href="/">Home</a>
18734        <div class="nav-dropdown">
18735          <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>
18736          <div class="nav-dropdown-menu">
18737            <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>
18738          </div>
18739        </div>
18740        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18741        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18742        <div class="nav-dropdown">
18743          <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>
18744          <div class="nav-dropdown-menu">
18745            <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>
18746          </div>
18747        </div>
18748        <div class="server-status-wrap" id="server-status-wrap">
18749          <div class="nav-pill server-online-pill" id="server-status-pill">
18750            <span class="status-dot" id="status-dot"></span>
18751            <span id="server-status-label">Server</span>
18752            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18753          </div>
18754          <div class="server-status-tip">
18755            OxideSLOC is running — accessible on your network.
18756            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18757          </div>
18758        </div>
18759        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18760          <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>
18761        </button>
18762        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18763          <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>
18764          <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>
18765        </button>
18766      </div>
18767    </div>
18768  </div>
18769
18770  <div class="page">
18771    {% if let Some(err) = browse_error %}
18772    <div class="toast-error">
18773      <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>
18774      {{ err }}
18775    </div>
18776    {% endif %}
18777    {% if linked_count > 0 %}
18778    <div class="toast-success">
18779      <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>
18780      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
18781    </div>
18782    {% endif %}
18783    <div class="watched-bar">
18784      <div class="watched-bar-left">
18785        <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>
18786        <span class="watched-label">Watched Folders</span>
18787        <div class="watched-chips">
18788          {% if server_mode %}
18789          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18790          {% else %}
18791          {% for dir in watched_dirs %}
18792          <span class="watched-chip">
18793            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18794            <form method="POST" action="/watched-dirs/remove" style="display:contents">
18795              <input type="hidden" name="folder_path" value="{{ dir }}">
18796              <input type="hidden" name="redirect_to" value="/view-reports">
18797              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
18798            </form>
18799          </span>
18800          {% endfor %}
18801          {% if watched_dirs.is_empty() %}
18802          <span class="watched-none">No folders watched — click Choose to add one</span>
18803          {% endif %}
18804          {% endif %}
18805        </div>
18806      </div>
18807      {% if !server_mode %}
18808      <div class="watched-bar-right">
18809        <button type="button" class="btn" id="add-watched-btn">
18810          <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>
18811          Choose
18812        </button>
18813        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18814          <input type="hidden" name="redirect_to" value="/view-reports">
18815          <button type="submit" class="btn">&#8635; Refresh</button>
18816        </form>
18817      </div>
18818      {% endif %}
18819    </div>
18820    {% if total_scans > 0 %}
18821    <div class="summary-strip">
18822      <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>
18823      <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>
18824      <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>
18825      <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>
18826    </div>
18827    {% endif %}
18828
18829    <section class="panel">
18830      <div class="panel-header">
18831        <div>
18832          <h1>View Reports</h1>
18833          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
18834          {% 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 %}
18835        </div>
18836        <div class="flex-row">
18837          <button type="button" class="export-btn" id="export-csv-btn">
18838            <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>
18839            Export CSV
18840          </button>
18841          <button type="button" class="export-btn" id="export-xls-btn">
18842            <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>
18843            Export Excel
18844          </button>
18845        </div>
18846      </div>
18847
18848      {% if entries.is_empty() %}
18849      <div class="empty-state">
18850        <strong>No reports with viewable HTML yet</strong>
18851        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.
18852      </div>
18853      {% else %}
18854      <div class="filter-row">
18855        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
18856        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18857        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
18858      </div>
18859      <div class="table-wrap">
18860        <table id="history-table">
18861          <colgroup>
18862            <col><col><col><col><col><col><col><col><col><col>
18863          </colgroup>
18864          <thead>
18865            <tr id="history-thead">
18866              <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>
18867              <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>
18868              <th>Run ID<div class="col-resize-handle"></div></th>
18869              <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>
18870              <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>
18871              <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>
18872              <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>
18873              <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>
18874              <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>
18875              <th>Report<div class="col-resize-handle"></div></th>
18876            </tr>
18877          </thead>
18878          <tbody id="history-tbody">
18879            {% for entry in entries %}
18880            <tr class="history-row" data-run="{{ entry.run_id }}"
18881                data-timestamp="{{ entry.timestamp }}"
18882                data-project="{{ entry.project_label }}"
18883                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
18884                data-skipped="{{ entry.files_skipped }}"
18885                data-comments="{{ entry.comment_lines }}"
18886                data-blank="{{ entry.blank_lines }}"
18887                data-branch="{{ entry.git_branch }}"
18888                data-commit="{{ entry.git_commit }}"
18889                data-html-url="/runs/html/{{ entry.run_id }}">
18890              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18891              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18892              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
18893              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
18894              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18895              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18896              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18897              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
18898              <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>
18899              <td class="report-cell">
18900                <div class="actions-cell">
18901                  {% 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 %}
18902                  {% 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 %}
18903                </div>
18904                {% if !entry.submodule_links.is_empty() %}
18905                <details class="submod-details">
18906                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
18907                  <div class="submod-link-list">
18908                    {% for sub in entry.submodule_links %}
18909                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
18910                    {% endfor %}
18911                  </div>
18912                </details>
18913                {% endif %}
18914              </td>
18915            </tr>
18916            {% endfor %}
18917          </tbody>
18918        </table>
18919      </div>
18920      <div class="pagination">
18921        <span class="pagination-info" id="pagination-info"></span>
18922        <div class="pagination-btns" id="pagination-btns"></div>
18923        <div class="flex-row">
18924          <span class="per-page-label">Show</span>
18925          <select class="per-page" id="per-page-sel">
18926            <option value="10">10 per page</option>
18927            <option value="25" selected>25 per page</option>
18928            <option value="50">50 per page</option>
18929            <option value="100">100 per page</option>
18930          </select>
18931          <span class="per-page-label" id="page-range-label"></span>
18932        </div>
18933      </div>
18934      {% endif %}
18935    </section>
18936  </div>
18937
18938  <footer class="site-footer">
18939    local code analysis - metrics, history and reports
18940    &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>
18941    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18942    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18943    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18944    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
18945  </footer>
18946
18947  <script nonce="{{ csp_nonce }}">
18948    (function () {
18949      // ── Theme ──────────────────────────────────────────────────────────────
18950      var storageKey = 'oxide-sloc-theme';
18951      var body = document.body;
18952      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
18953      var toggle = document.getElementById('theme-toggle');
18954      if (toggle) toggle.addEventListener('click', function () {
18955        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
18956        body.classList.toggle('dark-theme', next === 'dark');
18957        try { localStorage.setItem(storageKey, next); } catch(e) {}
18958      });
18959
18960      // ── State ─────────────────────────────────────────────────────────────
18961      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
18962      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
18963      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
18964
18965      // Aggregate stats from first (most recent) row
18966      if (allRows.length) {
18967        var first = allRows[0];
18968        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();}
18969        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>':'');}
18970        setChipVal('agg-code', first.dataset.code);
18971        setChipVal('agg-files', first.dataset.files);
18972        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
18973        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
18974      }
18975
18976      // ── Branch filter population ──────────────────────────────────────────
18977      (function() {
18978        var branches = {};
18979        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
18980        var sel = document.getElementById('branch-filter');
18981        if (sel) Object.keys(branches).sort().forEach(function(b) {
18982          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
18983        });
18984      })();
18985
18986      // ── Filter ────────────────────────────────────────────────────────────
18987      function getFilteredRows() {
18988        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
18989        var branch = ((document.getElementById('branch-filter') || {}).value || '');
18990        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
18991          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
18992          if (branch && (r.dataset.branch || '') !== branch) return false;
18993          return true;
18994        });
18995      }
18996
18997      // ── Pagination ────────────────────────────────────────────────────────
18998      function renderPage() {
18999        var filtered = getFilteredRows();
19000        var total = filtered.length;
19001        var totalPages = Math.max(1, Math.ceil(total / perPage));
19002        currentPage = Math.min(currentPage, totalPages);
19003        var start = (currentPage - 1) * perPage;
19004        var end = Math.min(start + perPage, total);
19005        var shown = {};
19006        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19007        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
19008          r.style.display = shown[r.dataset.run] ? '' : 'none';
19009        });
19010        var rl = document.getElementById('page-range-label');
19011        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19012        var info = document.getElementById('pagination-info');
19013        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19014        var btns = document.getElementById('pagination-btns');
19015        if (!btns) return;
19016        btns.innerHTML = '';
19017        function makeBtn(lbl, pg, active, disabled) {
19018          var b = document.createElement('button');
19019          b.className = 'pg-btn' + (active ? ' active' : '');
19020          b.textContent = lbl; b.disabled = disabled;
19021          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19022          return b;
19023        }
19024        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19025        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19026        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19027        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19028      }
19029
19030      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19031      window.applyFilters = function() { currentPage = 1; renderPage(); };
19032
19033      // ── Sorting ───────────────────────────────────────────────────────────
19034      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
19035      function doSort(col, type, order) {
19036        var tbody = document.getElementById('history-tbody');
19037        if (!tbody) return;
19038        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
19039        rows.sort(function(a, b) {
19040          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19041          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19042          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19043          return va < vb ? 1 : va > vb ? -1 : 0;
19044        });
19045        rows.forEach(function(r) { tbody.appendChild(r); });
19046        currentPage = 1; renderPage();
19047      }
19048      sortHeaders.forEach(function(th) {
19049        th.addEventListener('click', function(e) {
19050          if (e.target.classList.contains('col-resize-handle')) return;
19051          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19052          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19053          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19054          th.classList.add('sort-' + sortOrder);
19055          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19056          doSort(col, type, sortOrder);
19057        });
19058      });
19059
19060      // ── Column resize ─────────────────────────────────────────────────────
19061      (function() {
19062        var table = document.getElementById('history-table');
19063        if (!table) return;
19064        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19065        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
19066        ths.forEach(function(th, i) {
19067          var handle = th.querySelector('.col-resize-handle');
19068          if (!handle || !cols[i]) return;
19069          var startX, startW;
19070          handle.addEventListener('mousedown', function(e) {
19071            e.stopPropagation(); e.preventDefault();
19072            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19073            handle.classList.add('dragging');
19074            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19075            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19076            document.addEventListener('mousemove', onMove);
19077            document.addEventListener('mouseup', onUp);
19078          });
19079        });
19080      })();
19081
19082      // ── Reset view ────────────────────────────────────────────────────────
19083      window.resetView = function() {
19084        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19085        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19086        sortCol = null; sortOrder = 'asc';
19087        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19088        var tbody = document.getElementById('history-tbody');
19089        if (tbody) {
19090          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
19091          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19092          rows.forEach(function(r) { tbody.appendChild(r); });
19093        }
19094        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19095        var table = document.getElementById('history-table');
19096        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
19097        currentPage = 1; renderPage();
19098      };
19099
19100      renderPage();
19101
19102      // ── Export helpers ────────────────────────────────────────────────────
19103      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
19104      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
19105      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);}
19106      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;');}
19107      function slocXlsx(fname,sheet,hdrs,rows){
19108        var enc=new TextEncoder();
19109        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;}
19110        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;}
19111        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
19112        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
19113        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
19114        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;}
19115        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];}
19116        var rx='<row r="1">';
19117        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
19118        rx+='</row>';
19119        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>';});
19120        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
19121        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>';
19122        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>';
19123        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>';
19124        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>',
19125          '_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>',
19126          '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>',
19127          '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>',
19128          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
19129        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'];
19130        var zparts=[],zcds=[],zoff=0,znf=0;
19131        order.forEach(function(name){
19132          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
19133          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]);
19134          var entry=new Uint8Array(lha.length+nb.length+sz);
19135          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
19136          zparts.push(entry);
19137          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));
19138          var cde=new Uint8Array(cda.length+nb.length);
19139          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
19140          zcds.push(cde);zoff+=entry.length;znf++;
19141        });
19142        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
19143        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]);
19144        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
19145        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
19146        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
19147        zout.set(new Uint8Array(ea),zpos);
19148        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
19149      }
19150
19151      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
19152      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;}
19153      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
19154      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
19155
19156      var csvBtn = document.getElementById('export-csv-btn');
19157      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
19158      var xlsBtn = document.getElementById('export-xls-btn');
19159      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
19160
19161      // ── Remaining CSP-safe event bindings ────────────────────────────────
19162      (function wireEvents() {
19163        var el;
19164        el = document.getElementById('reset-view-btn');
19165        if (el) el.addEventListener('click', window.resetView);
19166        el = document.getElementById('project-filter');
19167        if (el) el.addEventListener('input', window.applyFilters);
19168        el = document.getElementById('branch-filter');
19169        if (el) el.addEventListener('change', window.applyFilters);
19170        el = document.getElementById('per-page-sel');
19171        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
19172        el = document.getElementById('add-watched-btn');
19173        if (el) el.addEventListener('click', function() {
19174          fetch('/pick-directory?kind=reports')
19175            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19176            .then(function(data) {
19177              if (!data.cancelled && data.selected_path) {
19178                var form = document.createElement('form');
19179                form.method = 'POST';
19180                form.action = '/watched-dirs/add';
19181                var ri = document.createElement('input');
19182                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19183                var fi = document.createElement('input');
19184                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19185                form.appendChild(ri); form.appendChild(fi);
19186                document.body.appendChild(form);
19187                form.submit();
19188              }
19189            })
19190            .catch(function(e) { alert('Could not open folder picker: ' + e); });
19191        });
19192      })();
19193
19194      (function randomizeWatermarks() {
19195        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19196        if (!wms.length) return;
19197        var placed = [];
19198        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;}
19199        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];}
19200        var half=Math.floor(wms.length/2);
19201        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;});
19202      })();
19203
19204      (function spawnCodeParticles() {
19205        var container = document.getElementById('code-particles');
19206        if (!container) return;
19207        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'];
19208        for (var i = 0; i < 38; i++) {
19209          (function(idx) {
19210            var el = document.createElement('span');
19211            el.className = 'code-particle';
19212            el.textContent = snippets[idx % snippets.length];
19213            var left = Math.random() * 94 + 2;
19214            var top = Math.random() * 88 + 6;
19215            var dur = (Math.random() * 10 + 9).toFixed(1);
19216            var delay = (Math.random() * 18).toFixed(1);
19217            var rot = (Math.random() * 26 - 13).toFixed(1);
19218            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19219            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';
19220            container.appendChild(el);
19221          })(i);
19222        }
19223      })();
19224    })();
19225  </script>
19226  <script nonce="{{ csp_nonce }}">
19227  (function(){
19228    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'}];
19229    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);});}
19230    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19231    function init(){
19232      var btn=document.getElementById('settings-btn');if(!btn)return;
19233      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19234      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>';
19235      document.body.appendChild(m);
19236      var g=document.getElementById('scheme-grid');
19237      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);});
19238      var cl=document.getElementById('settings-close');
19239      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);
19240      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');});
19241      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19242      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19243    }
19244    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19245  }());
19246  </script>
19247  <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>
19248</body>
19249</html>
19250"##,
19251    ext = "html"
19252)]
19253struct HistoryTemplate {
19254    version: &'static str,
19255    entries: Vec<HistoryEntryRow>,
19256    total_scans: usize,
19257    linked_count: usize,
19258    browse_error: Option<String>,
19259    watched_dirs: Vec<String>,
19260    csp_nonce: String,
19261    server_mode: bool,
19262}
19263
19264// ── CompareSelectTemplate ──────────────────────────────────────────────────────
19265
19266#[derive(Template)]
19267#[template(
19268    source = r##"
19269<!doctype html>
19270<html lang="en">
19271<head>
19272  <meta charset="utf-8">
19273  <meta name="viewport" content="width=device-width, initial-scale=1">
19274  <title>OxideSLOC | Compare Scans</title>
19275  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19276  <style nonce="{{ csp_nonce }}">
19277    :root {
19278      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
19279      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19280      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19281      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19282      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
19283    }
19284    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19285    *{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;}
19286    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19287    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19288    .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);}
19289    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19290    .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));}
19291    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19292    .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;}
19293    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19294    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19295    @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; } }
19296    .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;}
19297    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19298    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19299    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19300    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19301    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19302    .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;}
19303    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19304    .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);}
19305    .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;}
19306    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19307    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19308    .settings-modal-body{padding:14px 16px 16px;}
19309    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19310    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19311    .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;}
19312    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19313    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19314    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19315    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19316    .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;}
19317    .tz-select:focus{border-color:var(--oxide);}
19318    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
19319    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
19320    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
19321    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
19322    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
19323    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
19324    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
19325    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
19326    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
19327    .per-page-label{font-size:13px;color:var(--muted);}
19328    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;}
19329    .filter-input{min-width:180px;cursor:text;}
19330    .table-wrap{width:100%;overflow-x:auto;}
19331    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
19332    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;}
19333    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
19334    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
19335    #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;}
19336    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
19337    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
19338    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
19339    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
19340    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
19341    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
19342    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
19343    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
19344    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
19345    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
19346    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
19347    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
19348    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19349    tr:last-child td{border-bottom:none;}
19350    tr.selected td{background:var(--sel-bg);}
19351    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
19352    tr:hover:not(.selected) td{background:var(--surface-2);}
19353    tr{cursor:pointer;}
19354    .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);}
19355    .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);}
19356    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
19357    .metric-num{font-weight:700;color:var(--text);}
19358    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
19359    .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;}
19360    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
19361    .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;}
19362    .btn:hover{background:var(--line);}
19363    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
19364    .btn.primary:hover{opacity:.9;}
19365    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
19366    .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;}
19367    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
19368    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
19369    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
19370    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
19371    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
19372    .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;}
19373    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19374    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
19375    .watched-chip-rm:hover{color:var(--oxide);}
19376    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
19377    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
19378    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
19379    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
19380    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
19381    .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;}
19382    .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;}
19383    .btn-back:hover{background:var(--line);}
19384    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
19385    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
19386    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
19387    .pagination-info{font-size:13px;color:var(--muted);}
19388    .pagination-btns{display:flex;gap:6px;}
19389    .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;}
19390    .pg-btn:hover:not(:disabled){background:var(--line);}
19391    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19392    .pg-btn:disabled{opacity:.35;cursor:default;}
19393    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
19394    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19395    .site-footer a{color:var(--muted);}
19396    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
19397    .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;}
19398    .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;}
19399    .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;}
19400    @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));}}
19401    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
19402    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
19403    .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;}
19404    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
19405    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
19406    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
19407    .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);}
19408    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
19409    .stat-chip:hover .stat-chip-tip{opacity:1;}
19410    .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;}
19411    .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;}
19412    .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%;}
19413    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
19414    .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;}
19415    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
19416    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
19417    .hidden{display:none!important;}
19418    .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%;}
19419    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
19420    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
19421    .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;}
19422    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
19423    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
19424    .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;}
19425    .scope-option:hover{background:var(--line);}
19426    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
19427    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
19428    .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;}
19429    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
19430    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
19431    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
19432    .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;}
19433  </style>
19434</head>
19435<body>
19436  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19443  </div>
19444  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19445  <div class="top-nav">
19446    <div class="top-nav-inner">
19447      <a class="brand" href="/">
19448        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19449        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
19450      </a>
19451      <div class="nav-right">
19452        <a class="nav-pill" href="/">Home</a>
19453        <div class="nav-dropdown">
19454          <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>
19455          <div class="nav-dropdown-menu">
19456            <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>
19457          </div>
19458        </div>
19459        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19460        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19461        <div class="nav-dropdown">
19462          <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>
19463          <div class="nav-dropdown-menu">
19464            <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>
19465          </div>
19466        </div>
19467        <div class="server-status-wrap" id="server-status-wrap">
19468          <div class="nav-pill server-online-pill" id="server-status-pill">
19469            <span class="status-dot" id="status-dot"></span>
19470            <span id="server-status-label">Server</span>
19471            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19472          </div>
19473          <div class="server-status-tip">
19474            OxideSLOC is running — accessible on your network.
19475            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19476          </div>
19477        </div>
19478        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19479          <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>
19480        </button>
19481        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19482          <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>
19483          <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>
19484        </button>
19485      </div>
19486    </div>
19487  </div>
19488
19489  <div class="page">
19490    <div class="watched-bar">
19491      <div class="watched-bar-left">
19492        <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>
19493        <span class="watched-label">Watched Folders</span>
19494        <div class="watched-chips">
19495          {% if server_mode %}
19496          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
19497          {% else %}
19498          {% for dir in watched_dirs %}
19499          <span class="watched-chip">
19500            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
19501            <form method="POST" action="/watched-dirs/remove" style="display:contents">
19502              <input type="hidden" name="folder_path" value="{{ dir }}">
19503              <input type="hidden" name="redirect_to" value="/compare-scans">
19504              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
19505            </form>
19506          </span>
19507          {% endfor %}
19508          {% if watched_dirs.is_empty() %}
19509          <span class="watched-none">No folders watched — click Choose to add one</span>
19510          {% endif %}
19511          {% endif %}
19512        </div>
19513      </div>
19514      {% if !server_mode %}
19515      <div class="watched-bar-right">
19516        <button type="button" class="btn" id="add-watched-btn">
19517          <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>
19518          Choose
19519        </button>
19520        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
19521          <input type="hidden" name="redirect_to" value="/compare-scans">
19522          <button type="submit" class="btn">&#8635; Refresh</button>
19523        </form>
19524      </div>
19525      {% endif %}
19526    </div>
19527    {% if total_scans > 0 %}
19528    <div class="summary-strip">
19529      <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>
19530      <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>
19531      <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>
19532      <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>
19533    </div>
19534    {% endif %}
19535    <section class="panel">
19536      <div class="panel-header">
19537        <div>
19538          <h1>Compare Scans</h1>
19539          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
19540        </div>
19541        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
19542          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
19543            <button class="btn primary" id="compare-btn" disabled>
19544              <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>
19545              Compare <span class="sel-count" id="sel-count">0/2</span>
19546            </button>
19547          </div>
19548        </div>
19549      </div>
19550
19551      {% if entries.is_empty() %}
19552      <div class="empty-state">
19553        <strong>No scans yet</strong>
19554        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.
19555      </div>
19556      {% else %}
19557      <div class="filter-row">
19558        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
19559        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
19560        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
19561      </div>
19562      <div class="scope-panel hidden" id="scope-panel">
19563        <div class="scope-panel-label">
19564          <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>
19565          Compare scope — choose what to include
19566        </div>
19567        <div class="scope-options" id="scope-options"></div>
19568      </div>
19569      {% if total_scans > 0 %}
19570      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
19571        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
19572          <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>
19573          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
19574        </div>
19575      </div>
19576      {% endif %}
19577      <div class="table-wrap">
19578        <table id="compare-table">
19579          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
19580          <thead>
19581            <tr id="compare-thead">
19582              <th><div class="col-resize-handle"></div></th>
19583              <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>
19584              <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>
19585              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
19586              <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>
19587              <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>
19588              <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>
19589              <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>
19590              <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>
19591              <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>
19592              <th>Submodules<div class="col-resize-handle"></div></th>
19593            </tr>
19594          </thead>
19595          <tbody id="compare-tbody">
19596            {% for entry in entries %}
19597            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
19598                data-timestamp="{{ entry.timestamp }}"
19599                data-project="{{ entry.project_label }}"
19600                data-files="{{ entry.files_analyzed }}"
19601                data-code="{{ entry.code_lines }}"
19602                data-comments="{{ entry.comment_lines }}"
19603                data-blank="{{ entry.blank_lines }}"
19604                data-branch="{{ entry.git_branch }}"
19605                data-commit="{{ entry.git_commit }}"
19606                data-submodules="{{ entry.submodule_names_csv }}">
19607              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
19608              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
19609              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
19610              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
19611              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
19612              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
19613              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
19614              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
19615              <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>
19616              <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>
19617              <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>
19618            </tr>
19619            {% endfor %}
19620          </tbody>
19621        </table>
19622      </div>
19623      <div class="pagination">
19624        <span class="pagination-info" id="pagination-info"></span>
19625        <div class="pagination-btns" id="pagination-btns"></div>
19626        <div class="flex-row">
19627          <span class="per-page-label">Show</span>
19628          <select class="per-page" id="per-page-sel">
19629            <option value="10">10 per page</option>
19630            <option value="25" selected>25 per page</option>
19631            <option value="50">50 per page</option>
19632            <option value="100">100 per page</option>
19633          </select>
19634          <span class="per-page-label" id="page-range-label"></span>
19635        </div>
19636      </div>
19637      {% endif %}
19638    </section>
19639  </div>
19640
19641  <footer class="site-footer">
19642    local code analysis - metrics, history and reports
19643    &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>
19644    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19645    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19646    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19647    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19648  </footer>
19649
19650  <script nonce="{{ csp_nonce }}">
19651    (function () {
19652      // ── Theme ──────────────────────────────────────────────────────────────
19653      var storageKey = 'oxide-sloc-theme';
19654      var body = document.body;
19655      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19656      var toggle = document.getElementById('theme-toggle');
19657      if (toggle) toggle.addEventListener('click', function () {
19658        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19659        body.classList.toggle('dark-theme', next === 'dark');
19660        try { localStorage.setItem(storageKey, next); } catch(e) {}
19661      });
19662
19663      // ── State ─────────────────────────────────────────────────────────────
19664      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
19665      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
19666      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
19667
19668      // ── Stat chips ────────────────────────────────────────────────────────
19669      (function() {
19670        var projects = {}, latestTs = '', latestRow = null;
19671        allRows.forEach(function(r) {
19672          var p = r.dataset.project || ''; if (p) projects[p] = true;
19673          var ts = r.dataset.timestamp || '';
19674          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
19675        });
19676        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();}
19677        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>':'');}
19678        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
19679        if (latestRow) {
19680          setChipVal('agg-code', latestRow.dataset.code);
19681          setChipVal('agg-files', latestRow.dataset.files);
19682        }
19683      })();
19684
19685      // ── Branch filter population ──────────────────────────────────────────
19686      (function() {
19687        var branches = {};
19688        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
19689        var sel = document.getElementById('branch-filter');
19690        if (sel) Object.keys(branches).sort().forEach(function(b) {
19691          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
19692        });
19693      })();
19694
19695      // ── Filter ────────────────────────────────────────────────────────────
19696      function getFilteredRows() {
19697        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
19698        var branch = ((document.getElementById('branch-filter') || {}).value || '');
19699        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
19700          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
19701          if (branch && (r.dataset.branch || '') !== branch) return false;
19702          return true;
19703        });
19704      }
19705
19706      // ── Pagination ────────────────────────────────────────────────────────
19707      function renderPage() {
19708        var filtered = getFilteredRows();
19709        var total = filtered.length;
19710        var totalPages = Math.max(1, Math.ceil(total / perPage));
19711        currentPage = Math.min(currentPage, totalPages);
19712        var start = (currentPage - 1) * perPage;
19713        var end = Math.min(start + perPage, total);
19714        var shown = {};
19715        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19716        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
19717          r.style.display = shown[r.dataset.run] ? '' : 'none';
19718        });
19719        var rl = document.getElementById('page-range-label');
19720        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19721        var info = document.getElementById('pagination-info');
19722        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19723        var btns = document.getElementById('pagination-btns');
19724        if (!btns) return;
19725        btns.innerHTML = '';
19726        function makeBtn(lbl, pg, active, disabled) {
19727          var b = document.createElement('button');
19728          b.className = 'pg-btn' + (active ? ' active' : '');
19729          b.textContent = lbl; b.disabled = disabled;
19730          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19731          return b;
19732        }
19733        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19734        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19735        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19736        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19737      }
19738
19739      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19740      window.applyFilters = function() { currentPage = 1; renderPage(); };
19741
19742      // ── Sorting ───────────────────────────────────────────────────────────
19743      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
19744      function doSort(col, type, order) {
19745        var tbody = document.getElementById('compare-tbody');
19746        if (!tbody) return;
19747        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19748        rows.sort(function(a, b) {
19749          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19750          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19751          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19752          return va < vb ? 1 : va > vb ? -1 : 0;
19753        });
19754        rows.forEach(function(r) { tbody.appendChild(r); });
19755        currentPage = 1; renderPage();
19756      }
19757      sortHeaders.forEach(function(th) {
19758        th.addEventListener('click', function(e) {
19759          if (e.target.classList.contains('col-resize-handle')) return;
19760          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19761          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19762          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19763          th.classList.add('sort-' + sortOrder);
19764          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19765          doSort(col, type, sortOrder);
19766        });
19767      });
19768
19769      // Apply default sort (timestamp desc) on initial load
19770      (function() {
19771        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
19772        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
19773      })();
19774
19775      // ── Column resize ─────────────────────────────────────────────────────
19776      (function() {
19777        var table = document.getElementById('compare-table');
19778        if (!table) return;
19779        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19780        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
19781        ths.forEach(function(th, i) {
19782          var handle = th.querySelector('.col-resize-handle');
19783          if (!handle || !cols[i]) return;
19784          var startX, startW;
19785          handle.addEventListener('mousedown', function(e) {
19786            e.stopPropagation(); e.preventDefault();
19787            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19788            handle.classList.add('dragging');
19789            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19790            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19791            document.addEventListener('mousemove', onMove);
19792            document.addEventListener('mouseup', onUp);
19793          });
19794        });
19795      })();
19796
19797      // ── Reset view ────────────────────────────────────────────────────────
19798      window.resetView = function() {
19799        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19800        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19801        sortCol = null; sortOrder = 'asc';
19802        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19803        var tbody = document.getElementById('compare-tbody');
19804        if (tbody) {
19805          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19806          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19807          rows.forEach(function(r) { tbody.appendChild(r); });
19808        }
19809        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19810        var table = document.getElementById('compare-table');
19811        currentPage = 1; renderPage();
19812        currentPage = 1; renderPage();
19813      };
19814
19815      renderPage();
19816
19817      // ── Row selection state ───────────────────────────────────────────────
19818      var selected = [];
19819      function updateCompareBtn() {
19820        var btn = document.getElementById('compare-btn');
19821        var cnt = document.getElementById('sel-count');
19822        if (!btn) return;
19823        btn.disabled = selected.length !== 2;
19824        if (cnt) cnt.textContent = selected.length + '/2';
19825      }
19826
19827      function toggleRow(row) {
19828        var vid = row.dataset.vid || row.dataset.run;
19829        var idx = selected.indexOf(vid);
19830        if (idx >= 0) {
19831          selected.splice(idx, 1);
19832          row.classList.remove('selected');
19833          var b = document.getElementById('badge-' + vid);
19834          if (b) b.textContent = '';
19835        } else {
19836          if (selected.length >= 2) return;
19837          selected.push(vid);
19838          row.classList.add('selected');
19839        }
19840        selected.forEach(function(v, i) {
19841          var b = document.getElementById('badge-' + v);
19842          if (b) b.textContent = i + 1;
19843        });
19844        updateCompareBtn();
19845        buildScopePanel();
19846      }
19847
19848      // ── Scope panel ───────────────────────────────────────────────────────
19849      var selectedScope = 'all';
19850
19851      function buildScopePanel() {
19852        var panel = document.getElementById('scope-panel');
19853        var opts = document.getElementById('scope-options');
19854        if (!panel || !opts) return;
19855        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19856
19857        // Collect union of submodules from both selected rows.
19858        var allSubs = {};
19859        selected.forEach(function(vid) {
19860          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
19861          if (!row) return;
19862          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
19863        });
19864        var subList = Object.keys(allSubs).sort();
19865        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19866
19867        panel.classList.remove('hidden');
19868        opts.innerHTML = '';
19869
19870        function makeOption(value, label, title) {
19871          var div = document.createElement('div');
19872          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
19873          div.dataset.scopeValue = value;
19874          if (title) div.title = title;
19875          var radio = document.createElement('span');
19876          radio.className = 'scope-option-radio';
19877          var lbl = document.createElement('span');
19878          lbl.textContent = label;
19879          div.appendChild(radio);
19880          div.appendChild(lbl);
19881          div.addEventListener('click', function() {
19882            selectedScope = value;
19883            opts.querySelectorAll('.scope-option').forEach(function(o) {
19884              o.classList.toggle('selected', o.dataset.scopeValue === value);
19885            });
19886          });
19887          return div;
19888        }
19889
19890        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
19891        var sep = document.createElement('span');
19892        sep.className = 'scope-option-sep';
19893        opts.appendChild(sep);
19894        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
19895        subList.forEach(function(s) {
19896          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
19897        });
19898      }
19899
19900      function doCompare() {
19901        if (selected.length !== 2) return;
19902        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
19903        if (selectedScope === 'super') url += '&scope=super';
19904        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
19905        window.location.href = url;
19906      }
19907
19908      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
19909      var cbtn = document.getElementById('compare-btn');
19910      if (cbtn) cbtn.addEventListener('click', doCompare);
19911      var pfEl = document.getElementById('project-filter');
19912      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
19913      var bfEl = document.getElementById('branch-filter');
19914      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
19915      var rvBtn = document.getElementById('reset-view-btn');
19916      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
19917      var ppSel = document.getElementById('per-page-sel');
19918      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
19919
19920      var cmpTbody = document.getElementById('compare-tbody');
19921      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
19922        var row = e.target.closest('.compare-row');
19923        if (row) toggleRow(row);
19924      });
19925
19926      (function randomizeWatermarks() {
19927        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19928        if (!wms.length) return;
19929        var placed = [];
19930        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;}
19931        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];}
19932        var half=Math.floor(wms.length/2);
19933        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;});
19934      })();
19935
19936      (function spawnCodeParticles() {
19937        var container = document.getElementById('code-particles');
19938        if (!container) return;
19939        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'];
19940        for (var i = 0; i < 38; i++) {
19941          (function(idx) {
19942            var el = document.createElement('span');
19943            el.className = 'code-particle';
19944            el.textContent = snippets[idx % snippets.length];
19945            var left = Math.random() * 94 + 2;
19946            var top = Math.random() * 88 + 6;
19947            var dur = (Math.random() * 10 + 9).toFixed(1);
19948            var delay = (Math.random() * 18).toFixed(1);
19949            var rot = (Math.random() * 26 - 13).toFixed(1);
19950            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19951            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';
19952            container.appendChild(el);
19953          })(i);
19954        }
19955      })();
19956
19957      // ── Watched folder picker ─────────────────────────────────────────────
19958      (function() {
19959        var btn = document.getElementById('add-watched-btn');
19960        if (!btn) return;
19961        btn.addEventListener('click', function() {
19962          fetch('/pick-directory?kind=reports')
19963            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19964            .then(function(data) {
19965              if (!data.cancelled && data.selected_path) {
19966                var form = document.createElement('form');
19967                form.method = 'POST';
19968                form.action = '/watched-dirs/add';
19969                var ri = document.createElement('input');
19970                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19971                var fi = document.createElement('input');
19972                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19973                form.appendChild(ri); form.appendChild(fi);
19974                document.body.appendChild(form);
19975                form.submit();
19976              }
19977            })
19978            .catch(function(e) { alert('Could not open folder picker: ' + e); });
19979        });
19980      })();
19981
19982      // ── Submodule chip truncation ─────────────────────────────────────────
19983      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
19984        var chips = cell.querySelectorAll('.submod-chip');
19985        var MAX = 4;
19986        if (chips.length <= MAX) return;
19987        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
19988        var badge = document.createElement('span');
19989        badge.className = 'submod-overflow-badge';
19990        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
19991        badge.textContent = '+' + (chips.length - MAX) + ' more';
19992        cell.appendChild(badge);
19993        cell.style.maxHeight = 'none';
19994      });
19995    })();
19996  </script>
19997  <script nonce="{{ csp_nonce }}">
19998  (function(){
19999    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'}];
20000    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);});}
20001    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20002    function init(){
20003      var btn=document.getElementById('settings-btn');if(!btn)return;
20004      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20005      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>';
20006      document.body.appendChild(m);
20007      var g=document.getElementById('scheme-grid');
20008      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);});
20009      var cl=document.getElementById('settings-close');
20010      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);
20011      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');});
20012      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20013      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20014    }
20015    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20016  }());
20017  </script>
20018  <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>
20019</body>
20020</html>
20021"##,
20022    ext = "html"
20023)]
20024struct CompareSelectTemplate {
20025    version: &'static str,
20026    entries: Vec<HistoryEntryRow>,
20027    total_scans: usize,
20028    watched_dirs: Vec<String>,
20029    csp_nonce: String,
20030    server_mode: bool,
20031}
20032
20033// ── CompareTemplate ────────────────────────────────────────────────────────────
20034
20035#[derive(Template)]
20036#[template(
20037    source = r##"
20038<!doctype html>
20039<html lang="en">
20040<head>
20041  <meta charset="utf-8">
20042  <meta name="viewport" content="width=device-width, initial-scale=1">
20043  <title>OxideSLOC | Scan Delta</title>
20044  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20045  <style nonce="{{ csp_nonce }}">
20046    :root {
20047      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
20048      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
20049      --nav:#283790; --nav-2:#013e6b;
20050      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
20051      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
20052      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
20053    }
20054    body.dark-theme {
20055      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
20056      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
20057    }
20058    *{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;}
20059    .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);}
20060    .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;}
20061    .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));}
20062    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20063    .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;}
20064    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
20065    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20066    @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; } }
20067    .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;}
20068    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
20069    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20070    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20071    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20072    .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;}
20073    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20074    .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);}
20075    .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;}
20076    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20077    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20078    .settings-modal-body{padding:14px 16px 16px;}
20079    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20080    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20081    .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;}
20082    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20083    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20084    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20085    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20086    .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;}
20087    .tz-select:focus{border-color:var(--oxide);}
20088    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
20089    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
20090    .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;}
20091    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
20092    .hero-body{display:block;}
20093    .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;}
20094    .btn-back:hover{background:var(--line);}
20095    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
20096    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
20097    .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;}
20098    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
20099    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;}
20100    .muted{color:var(--muted);font-size:14px;}
20101    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
20102    .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;}
20103    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
20104    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
20105    .vpill-arrow{font-size:20px;color:var(--muted);}
20106    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
20107    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
20108    .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;}
20109    .delta-card.delta-card-wide{padding:22px 24px;}
20110    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
20111    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
20112    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
20113    .delta-card-from{font-size:15px;color:var(--muted);}
20114    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
20115    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
20116    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
20117    .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%;}
20118    .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;}
20119    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
20120    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
20121    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
20122    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
20123    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
20124    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
20125    .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;}
20126    .meta-card-commit:hover{color:var(--oxide);}
20127    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
20128    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
20129    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
20130    .meta-value{color:var(--text);font-size:13px;}
20131    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
20132    .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;}
20133    .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);}
20134    .delta-card:hover .dc-tip{display:block;}
20135    .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;}
20136    .export-btn:hover{background:var(--line);}
20137    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
20138    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
20139    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
20140    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
20141    .delta-card-change.zero{color:var(--muted);background:transparent;}
20142    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
20143    .delta-card-pct.pos{color:var(--pos);}
20144    .delta-card-pct.neg{color:var(--neg);}
20145    .delta-card-pct.zero{color:var(--muted);}
20146    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
20147    .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;}
20148    .insight-card.insight-flag{border-color:var(--oxide);}
20149    .insight-card:hover .dc-tip{display:block;}
20150    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
20151    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
20152    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
20153    .insight-label.flag{color:var(--oxide);}
20154    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
20155    .insight-val.pos{color:var(--pos);}
20156    .insight-val.neg{color:var(--neg);}
20157    .insight-val.high{color:#c0392a;}
20158    .insight-val.med{color:#926000;}
20159    .insight-val.low{color:var(--pos);}
20160    body.dark-theme .insight-val.high{color:#ff6b6b;}
20161    body.dark-theme .insight-val.med{color:#f0c060;}
20162    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
20163    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
20164    .fc-row{display:flex;align-items:center;gap:8px;}
20165    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
20166    .fc-label{color:var(--muted);}
20167    .fc-modified .fc-count{color:#926000;}
20168    .fc-added .fc-count{color:var(--pos);}
20169    .fc-removed .fc-count{color:var(--neg);}
20170    .fc-unchanged .fc-count{color:var(--muted);}
20171    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
20172    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
20173    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
20174    .chip.modified{background:#fff2d8;color:#926000;}
20175    .chip.added{background:#e8f5ed;color:#1a8f47;}
20176    .chip.removed{background:#fdeaea;color:#b33b3b;}
20177    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
20178    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
20179    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
20180    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
20181    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
20182    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
20183    .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;}
20184    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
20185    .tab-btn:hover:not(.active){background:var(--line);}
20186    .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;}
20187    .btn-reset:hover{background:var(--line);}
20188    .table-wrap{width:100%;overflow-x:auto;}
20189    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
20190    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;}
20191    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
20192    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
20193    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
20194    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
20195    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
20196    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
20197    tr:last-child td{border-bottom:none;}
20198    tr.row-added td{background:rgba(26,143,71,0.06);}
20199    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
20200    tr.row-modified td{background:rgba(146,96,0,0.05);}
20201    tr.row-unchanged td{opacity:.6;}
20202    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
20203    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
20204    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
20205    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
20206    .status-badge.modified{background:#fff2d8;color:#926000;}
20207    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
20208    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
20209    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
20210    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
20211    .delta-val{font-weight:700;}
20212    .delta-val.pos{color:var(--pos);}
20213    .delta-val.neg{color:var(--neg);}
20214    .delta-val.zero{color:var(--muted);}
20215    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
20216    .from-to strong{color:var(--text);}
20217    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20218    .site-footer a{color:var(--muted);}
20219    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
20220    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
20221    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20222    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20223    .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;}
20224    .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;}
20225    .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;}
20226    @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));}}
20227    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
20228    .path-link:hover{color:var(--oxide-2);}
20229    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
20230    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
20231    a.vpill-id:hover{color:var(--oxide);}
20232    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
20233    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
20234    .pagination-info{font-size:13px;color:var(--muted);}
20235    .pagination-btns{display:flex;gap:6px;}
20236    .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;}
20237    .pg-btn:hover:not(:disabled){background:var(--line);}
20238    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20239    .pg-btn:disabled{opacity:.35;cursor:default;}
20240    .per-page-label{font-size:13px;color:var(--muted);}
20241    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;}
20242    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20243    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
20244    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
20245    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
20246    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
20247    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
20248    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
20249    .tab-btn.tab-unchanged{color:var(--muted);}
20250    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
20251    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
20252    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
20253    .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;}
20254    .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;}
20255    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
20256    .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;}
20257    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
20258    .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;}
20259    .submod-scope-btn:hover{background:var(--line);}
20260    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20261    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
20262    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
20263    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
20264    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
20265    body.dark-theme .ic-card{background:var(--surface-2);}
20266    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
20267    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
20268    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
20269    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
20270    #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;}
20271  </style>
20272</head>
20273<body>
20274  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20281  </div>
20282  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20283  <div class="top-nav">
20284    <div class="top-nav-inner">
20285      <a class="brand" href="/">
20286        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
20287        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
20288      </a>
20289      <div class="nav-right">
20290        <a class="nav-pill" href="/">Home</a>
20291        <div class="nav-dropdown">
20292          <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>
20293          <div class="nav-dropdown-menu">
20294            <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>
20295          </div>
20296        </div>
20297        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20298        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20299        <div class="nav-dropdown">
20300          <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>
20301          <div class="nav-dropdown-menu">
20302            <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>
20303          </div>
20304        </div>
20305        <div class="server-status-wrap" id="server-status-wrap">
20306          <div class="nav-pill server-online-pill" id="server-status-pill">
20307            <span class="status-dot" id="status-dot"></span>
20308            <span id="server-status-label">Server</span>
20309            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20310          </div>
20311          <div class="server-status-tip">
20312            OxideSLOC is running — accessible on your network.
20313            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20314          </div>
20315        </div>
20316        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20317          <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>
20318        </button>
20319        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20320          <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>
20321          <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>
20322        </button>
20323      </div>
20324    </div>
20325  </div>
20326
20327  <div class="page">
20328    <section class="hero">
20329      <div class="hero-header">
20330        <div>
20331          <h1 class="delta-title">Scan Delta</h1>
20332          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
20333          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
20334            {% if let Some(sub) = active_submodule %}
20335            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
20336            {% else if super_scope_active %}
20337            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
20338            {% else %}
20339            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
20340            {% endif %}
20341            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
20342          </div>
20343        </div>
20344        <a class="btn-back" href="/compare-scans">
20345          <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>
20346          Compare Scans
20347        </a>
20348      </div>
20349      {% if has_any_submodule_data %}
20350      <div class="submod-scope-bar">
20351        <span class="submod-scope-label">
20352          <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>
20353          Scope:
20354        </span>
20355        <div class="submod-scope-divider"></div>
20356        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
20357           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
20358           title="All files — super-repo and all submodules combined">Full scan</a>
20359        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
20360           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
20361           title="Only files that are not part of any submodule">Super-repo only</a>
20362        {% for sub in submodule_options %}
20363        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
20364           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
20365           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
20366        {% endfor %}
20367      </div>
20368      {% endif %}
20369      <div class="hero-body">
20370      <div class="meta-strip">
20371        <div class="delta-card delta-card-meta">
20372          <div class="meta-card-header">
20373            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
20374            <div class="meta-card-project-col">
20375              <div class="meta-card-project">{{ project_name }}</div>
20376              {% if has_any_submodule_data %}
20377              {% if let Some(sub) = active_submodule %}
20378              <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>
20379              {% else if super_scope_active %}
20380              <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>
20381              {% else %}
20382              <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>
20383              {% endif %}
20384              {% endif %}
20385            </div>
20386          </div>
20387          {% if !baseline_git_commit.is_empty() %}
20388          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
20389          {% else %}
20390          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
20391          {% endif %}
20392          <div class="meta-card-rows">
20393            <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>
20394            <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>
20395            <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>
20396            <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>
20397            {% if let Some(tags) = baseline_git_tags %}
20398            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
20399            {% endif %}
20400          </div>
20401        </div>
20402        <div class="delta-card delta-card-meta">
20403          <div class="meta-card-header">
20404            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
20405            <div class="meta-card-project-col">
20406              <div class="meta-card-project">{{ project_name }}</div>
20407              {% if has_any_submodule_data %}
20408              {% if let Some(sub) = active_submodule %}
20409              <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>
20410              {% else if super_scope_active %}
20411              <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>
20412              {% else %}
20413              <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>
20414              {% endif %}
20415              {% endif %}
20416            </div>
20417          </div>
20418          {% if !current_git_commit.is_empty() %}
20419          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
20420          {% else %}
20421          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
20422          {% endif %}
20423          <div class="meta-card-rows">
20424            <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>
20425            <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>
20426            <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>
20427            <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>
20428            {% if let Some(tags) = current_git_tags %}
20429            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
20430            {% endif %}
20431          </div>
20432        </div>
20433      </div>
20434      <div class="delta-strip">
20435        <div class="delta-card">
20436          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
20437          <div class="delta-card-label">Code lines</div>
20438          <div class="delta-card-from">Before: {{ baseline_code }}</div>
20439          <div class="delta-card-to">{{ current_code }}</div>
20440          {% 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>
20441          {% 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>
20442          {% else %}<div class="delta-card-pct zero">±0%</div>
20443          {% endif %}
20444        </div>
20445        <div class="delta-card">
20446          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
20447          <div class="delta-card-label">Files analyzed</div>
20448          <div class="delta-card-from">Before: {{ baseline_files }}</div>
20449          <div class="delta-card-to">{{ current_files }}</div>
20450          {% 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>
20451          {% 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>
20452          {% else %}<div class="delta-card-pct zero">±0%</div>
20453          {% endif %}
20454        </div>
20455        <div class="delta-card">
20456          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
20457          <div class="delta-card-label">Comment lines</div>
20458          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
20459          <div class="delta-card-to">{{ current_comments }}</div>
20460          {% 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>
20461          {% 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>
20462          {% else %}<div class="delta-card-pct zero">±0%</div>
20463          {% endif %}
20464        </div>
20465        {{ coverage_delta_card|safe }}
20466        <div class="delta-card delta-card-wide">
20467          <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>
20468          <div class="delta-card-label">File changes</div>
20469          <div class="file-changes-grid">
20470            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
20471            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
20472            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
20473            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
20474          </div>
20475        </div>
20476      </div>
20477      <div class="insights-panel">
20478        <div class="insight-card">
20479          <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>
20480          <div class="insight-label">Lines Added</div>
20481          <div class="insight-val pos">+{{ code_lines_added }}</div>
20482          <div class="insight-sub">New or grown source lines</div>
20483        </div>
20484        <div class="insight-card">
20485          <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>
20486          <div class="insight-label">Lines Removed</div>
20487          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
20488          <div class="insight-sub">Deleted or shrunk source lines</div>
20489        </div>
20490        <div class="insight-card">
20491          <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>
20492          <div class="insight-label">Churn Rate</div>
20493          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
20494          <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>
20495        </div>
20496        {% if scope_flag %}
20497        <div class="insight-card insight-flag">
20498          <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>
20499          <div class="insight-label flag">Scope Signal</div>
20500          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
20501          <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>
20502        </div>
20503        {% endif %}
20504      </div>
20505      </div>
20506    </section>
20507
20508    <section class="panel" id="inline-charts-section">
20509      <h2>Scan Delta Charts</h2>
20510      <div class="ic-grid">
20511        <div class="ic-card">
20512          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
20513          <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>
20514          <div id="ic-c1"></div>
20515        </div>
20516        <div class="ic-card" id="ic-lang-card">
20517          <div class="ic-card-h2">Language Code Delta</div>
20518          <div id="ic-c3"></div>
20519        </div>
20520        <div class="ic-card">
20521          <div class="ic-card-h2">Delta by Metric</div>
20522          <div id="ic-c2"></div>
20523        </div>
20524        <div class="ic-card">
20525          <div class="ic-card-h2">File Change Distribution</div>
20526          <div id="ic-c4"></div>
20527        </div>
20528      </div>
20529    </section>
20530
20531    <section class="panel">
20532      <h2>File-level delta</h2>
20533      <div class="filter-tabs-row">
20534        <div class="filter-tabs">
20535          <button class="tab-btn tab-all active" data-filter="all">All</button>
20536          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
20537          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
20538          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
20539          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
20540        </div>
20541        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
20542          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
20543          <div class="export-group">
20544            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
20545            <button type="button" class="export-btn" id="delta-csv-btn">
20546              <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>
20547              CSV
20548            </button>
20549            <button type="button" class="export-btn" id="delta-xls-btn">
20550              <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>
20551              Excel
20552            </button>
20553            <button type="button" class="export-btn" id="delta-charts-btn">
20554              <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>
20555              Charts
20556            </button>
20557          </div>
20558        </div>
20559      </div>
20560
20561      <div class="table-wrap">
20562      <table id="delta-table">
20563        <colgroup>
20564          <col>
20565          <col>
20566          <col>
20567          <col>
20568          <col>
20569          <col>
20570          <col>
20571        </colgroup>
20572        <thead>
20573          <tr id="delta-thead">
20574            <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>
20575            <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>
20576            <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>
20577            <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>
20578            <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>
20579            <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>
20580            <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>
20581          </tr>
20582        </thead>
20583        <tbody id="delta-tbody">
20584          {% for row in file_rows %}
20585          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
20586              data-path="{{ row.relative_path }}"
20587              data-language="{{ row.language }}"
20588              data-baseline-code="{{ row.baseline_code }}"
20589              data-current-code="{{ row.current_code }}"
20590              data-code-delta="{{ row.code_delta_str }}"
20591              data-comment-delta="{{ row.comment_delta_str }}"
20592              data-total-delta="{{ row.total_delta_str }}"
20593              data-orig-idx="">
20594            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
20595            <td class="hide-sm">{{ row.language }}</td>
20596            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
20597            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
20598            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
20599            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
20600            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
20601          </tr>
20602          {% endfor %}
20603        </tbody>
20604      </table>
20605      </div>
20606      <div class="pagination">
20607        <span class="pagination-info" id="pg-info"></span>
20608        <div class="pagination-btns" id="pg-btns"></div>
20609        <div class="flex-row">
20610          <span class="per-page-label">Show</span>
20611          <select class="per-page" id="per-page-sel">
20612            <option value="10">10 per page</option>
20613            <option value="25" selected>25 per page</option>
20614            <option value="50">50 per page</option>
20615            <option value="100">100 per page</option>
20616          </select>
20617          <span class="per-page-label" id="pg-range-label"></span>
20618        </div>
20619      </div>
20620    </section>
20621  </div>
20622
20623  <div id="ic-tt"></div>
20624
20625  <footer class="site-footer">
20626    local code analysis - metrics, history and reports
20627    &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>
20628    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20629    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20630    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20631    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20632  </footer>
20633
20634  <script nonce="{{ csp_nonce }}">
20635    (function () {
20636      var storageKey = 'oxide-sloc-theme';
20637      var body = document.body;
20638      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20639      var toggle = document.getElementById('theme-toggle');
20640      if (toggle) toggle.addEventListener('click', function () {
20641        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20642        body.classList.toggle('dark-theme', next === 'dark');
20643        try { localStorage.setItem(storageKey, next); } catch(e) {}
20644      });
20645
20646      (function randomizeWatermarks() {
20647        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20648        if (!wms.length) return;
20649        var placed = [];
20650        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;}
20651        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];}
20652        var half=Math.floor(wms.length/2);
20653        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;});
20654      })();
20655
20656      (function spawnCodeParticles() {
20657        var container = document.getElementById('code-particles');
20658        if (!container) return;
20659        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'];
20660        for (var i = 0; i < 38; i++) {
20661          (function(idx) {
20662            var el = document.createElement('span');
20663            el.className = 'code-particle';
20664            el.textContent = snippets[idx % snippets.length];
20665            var left = Math.random() * 94 + 2;
20666            var top = Math.random() * 88 + 6;
20667            var dur = (Math.random() * 10 + 9).toFixed(1);
20668            var delay = (Math.random() * 18).toFixed(1);
20669            var rot = (Math.random() * 26 - 13).toFixed(1);
20670            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20671            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';
20672            container.appendChild(el);
20673          })(i);
20674        }
20675      })();
20676    })();
20677
20678    var activeStatusFilter = 'all';
20679    var deltaPerPage = 25, deltaCurrPage = 1;
20680
20681    function openFolder(path) {
20682      fetch('/open-path?path=' + encodeURIComponent(path))
20683        .then(function (r) { return r.json(); })
20684        .then(function (d) {
20685          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20686        })
20687        .catch(function () {});
20688    }
20689
20690    function getDeltaFilteredRows() {
20691      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
20692        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
20693      });
20694    }
20695
20696    function renderDeltaPage() {
20697      var filtered = getDeltaFilteredRows();
20698      var total = filtered.length;
20699      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
20700      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
20701      var start = (deltaCurrPage - 1) * deltaPerPage;
20702      var end = Math.min(start + deltaPerPage, total);
20703      var shownSet = {};
20704      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
20705      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
20706        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
20707      });
20708      var rl = document.getElementById('pg-range-label');
20709      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
20710      var info = document.getElementById('pg-info');
20711      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
20712      var btns = document.getElementById('pg-btns');
20713      if (!btns) return;
20714      btns.innerHTML = '';
20715      if (totalPages <= 1) return;
20716      function makeBtn(lbl, pg, active, disabled) {
20717        var b = document.createElement('button');
20718        b.className = 'pg-btn' + (active ? ' active' : '');
20719        b.textContent = lbl; b.disabled = disabled;
20720        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
20721        return b;
20722      }
20723      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
20724      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
20725      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
20726      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
20727    }
20728
20729    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
20730
20731    function filterRows(status, btn) {
20732      activeStatusFilter = status;
20733      deltaCurrPage = 1;
20734      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
20735        b.classList.remove('active');
20736      });
20737      if (btn) btn.classList.add('active');
20738      renderDeltaPage();
20739    }
20740
20741    // ── Sorting ──────────────────────────────────────────────────────────────
20742    var sortCol = null, sortOrder = 'asc';
20743    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
20744    (function() {
20745      var tbody = document.getElementById('delta-tbody');
20746      if (!tbody) return;
20747      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20748      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
20749    })();
20750
20751    function parseDeltaNum(str) {
20752      if (!str || str === '—') return 0;
20753      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
20754    }
20755
20756    sortHeaders.forEach(function(th) {
20757      th.addEventListener('click', function(e) {
20758        if (e.target.classList.contains('col-resize-handle')) return;
20759        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
20760        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
20761        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20762        th.classList.add('sort-' + sortOrder);
20763        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
20764        var tbody = document.getElementById('delta-tbody');
20765        if (!tbody) return;
20766        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20767        rows.sort(function(a, b) {
20768          var va, vb;
20769          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
20770          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
20771          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
20772          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
20773          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20774          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20775          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20776          else { va = ''; vb = ''; }
20777          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
20778          return va < vb ? 1 : va > vb ? -1 : 0;
20779        });
20780        rows.forEach(function(r) { tbody.appendChild(r); });
20781        deltaCurrPage = 1;
20782        renderDeltaPage();
20783        var activeBtn = document.querySelector('.tab-btn.active');
20784        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20785        if (activeBtn) activeBtn.classList.add('active');
20786      });
20787    });
20788
20789    // ── Column resize ─────────────────────────────────────────────────────────
20790    (function() {
20791      var table = document.getElementById('delta-table');
20792      if (!table) return;
20793      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
20794      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
20795      ths.forEach(function(th, i) {
20796        var handle = th.querySelector('.col-resize-handle');
20797        if (!handle || !cols[i]) return;
20798        var startX, startW;
20799        handle.addEventListener('mousedown', function(e) {
20800          e.stopPropagation(); e.preventDefault();
20801          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
20802          handle.classList.add('dragging');
20803          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
20804          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
20805          document.addEventListener('mousemove', onMove);
20806          document.addEventListener('mouseup', onUp);
20807        });
20808      });
20809    })();
20810
20811    // ── Reset ─────────────────────────────────────────────────────────────────
20812    window.resetDeltaTable = function() {
20813      sortCol = null; sortOrder = 'asc';
20814      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20815      var tbody = document.getElementById('delta-tbody');
20816      if (tbody) {
20817        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20818        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
20819        rows.forEach(function(r) { tbody.appendChild(r); });
20820      }
20821      var table = document.getElementById('delta-table');
20822      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
20823      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
20824      activeStatusFilter = 'all';
20825      deltaCurrPage = 1;
20826      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20827      var allBtn = document.querySelector('.tab-btn');
20828      if (allBtn) allBtn.classList.add('active');
20829      renderDeltaPage();
20830    };
20831
20832    renderDeltaPage();
20833
20834    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
20835    (function() {
20836      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
20837        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
20838      });
20839      var resetBtn = document.getElementById('delta-reset-btn');
20840      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
20841      var csvBtn = document.getElementById('delta-csv-btn');
20842      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
20843      var xlsBtn = document.getElementById('delta-xls-btn');
20844      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
20845      var chartsBtn = document.getElementById('delta-charts-btn');
20846      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
20847      var ppSel = document.getElementById('per-page-sel');
20848      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
20849      var pathLink = document.getElementById('project-path-link');
20850      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
20851    })();
20852
20853    // ── Export helpers ────────────────────────────────────────────────────────
20854    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
20855    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
20856    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);}
20857    function slocMakeXlsx(fname,sd,dr){
20858      var enc=new TextEncoder();
20859      // CRC-32 table
20860      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;}
20861      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;}
20862      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
20863      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
20864      // Shared string table
20865      var ss=[],si={};
20866      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
20867      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20868      // Worksheet builder — each WS() call gets its own row counter R
20869      function WS(){
20870        var R=0,buf=[];
20871        function cl(c){return String.fromCharCode(65+c);}
20872        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
20873          '<v>'+S(v)+'</v></c>';}
20874        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
20875          (st?' s="'+st+'"':'')+'>'+
20876          '<v>'+(+v)+'</v></c>';}
20877        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
20878        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20879          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
20880          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
20881          '<sheetFormatPr defaultRowHeight="15"/>'+
20882          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
20883        return{sc:sc,nc:nc,row:row,xml:xml};
20884      }
20885      // Language breakdown
20886      var lm={};
20887      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;});
20888      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
20889      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
20890      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
20891      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
20892      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20893      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20894      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):'';}
20895      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
20896      // Summary sheet
20897      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
20898      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
20899      r1(s1(0,proj,2));
20900      r1(s1(0,sd.bts+' → '+sd.cts,2));
20901      r1('');
20902      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
20903      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))));
20904      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))));
20905      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))));
20906      r1('');
20907      r1(s1(0,'FILE CHANGES',8));
20908      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
20909      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
20910      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
20911      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
20912      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
20913      if(langs.length){
20914        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
20915        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
20916        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)));});
20917      }
20918      r1('');r1(s1(0,'SCAN METADATA',8));
20919      r1(s1(1,_blabel)+s1(2,_clabel));
20920      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
20921      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
20922      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"/>');
20923      // File Delta sheet
20924      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
20925      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));
20926      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)));});
20927      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
20928      // Shared strings XML
20929      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20930        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
20931        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
20932      // XLSX file map
20933      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
20934      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>',
20935        '_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>',
20936        '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>',
20937        '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>',
20938        '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>',
20939        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
20940      // ZIP packer — STORED (no compression), compatible with all XLSX readers
20941      var zparts=[],zcds=[],zoff=0,znf=0;
20942      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
20943       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
20944      ].forEach(function(name){
20945        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
20946        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]);
20947        var entry=new Uint8Array(lha.length+nb.length+sz);
20948        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
20949        zparts.push(entry);
20950        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));
20951        var cde=new Uint8Array(cda.length+nb.length);
20952        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
20953        zcds.push(cde);zoff+=entry.length;znf++;
20954      });
20955      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
20956      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]);
20957      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
20958      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
20959      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
20960      zout.set(new Uint8Array(ea),zpos);
20961      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
20962      var xurl=URL.createObjectURL(xblob);
20963      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
20964      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
20965      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
20966    }
20967    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;');}
20968    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
20969    function getExportFilename(ext){return _exportBase+'.'+ext;}
20970
20971    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 }}'};
20972    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;}
20973    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
20974    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
20975    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20976    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20977    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):'';}
20978    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
20979    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)]];}
20980    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
20981    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;}
20982    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
20983    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
20984
20985    // ── Chart HTML report ─────────────────────────────────────────────────────
20986    function slocChartReport(fname, sd, dr) {
20987      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
20988      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20989      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20990      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();}
20991      function px(n){return Math.round(n);}
20992      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
20993      // Language map
20994      var lm={};
20995      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;});
20996      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20997
20998      // Builds onmouse* attrs for interactive tooltip on each SVG element
20999      function barTT(label,val){
21000        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
21001      }
21002
21003      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
21004      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'}];
21005      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
21006      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
21007      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
21008      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21009      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"/>';}
21010      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
21011      c1mets.forEach(function(m,i){
21012        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
21013        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
21014        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>';
21015        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))+'/>';
21016        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>';
21017        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))+'/>';
21018        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>';
21019        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>';
21020        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>';
21021      });
21022      c1+='</svg>';
21023
21024      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
21025      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'}];
21026      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
21027      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
21028      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
21029      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21030      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21031      mets.forEach(function(m,i){
21032        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
21033        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
21034        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
21035        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>';
21036        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
21037        if(bw>=52){
21038          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>';
21039        }else{
21040          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
21041          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>';
21042        }
21043      });
21044      c2+='</svg>';
21045
21046      // ── Chart 3: Language Code Delta ─────────────────────────────────────
21047      var c3='';
21048      if(langs.length){
21049        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
21050        var C3W=550,c3LW=124,c3FW=52;
21051        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
21052        var L3rH=30,C3H=langs.length*L3rH+20;
21053        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21054        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21055        langs.forEach(function(l,i){
21056          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
21057          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
21058          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
21059          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
21060          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':''))+'/>';
21061          if(bw>=48){
21062            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>';
21063          }else{
21064            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
21065            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>';
21066          }
21067          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>';
21068        });
21069        c3+='</svg>';
21070      }
21071
21072      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
21073      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;});
21074      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
21075      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
21076      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21077      var ang=-Math.PI/2;
21078      segs.forEach(function(s){
21079        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21080        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
21081        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
21082        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
21083        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
21084        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)+'%')+'/>';
21085        ang+=sw;
21086      });
21087      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>';
21088      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
21089      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>';});
21090      c4+='</svg>';
21091
21092      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
21093      var ttJs='var tt=document.getElementById("ox-tt");'+
21094        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
21095        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
21096        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
21097        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
21098        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
21099        'function oxHT(){tt.style.display="none";}';
21100
21101      // body max-width keeps charts from inflating beyond design dimensions on
21102      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
21103      // each chart's height blows up proportionally, breaking the one-page layout.
21104      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;}'+
21105        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
21106        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
21107        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
21108        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
21109        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
21110        'svg{display:block;}'+
21111        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
21112        '#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;}'+
21113        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
21114      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
21115        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
21116        '<div id="ox-tt"><\/div>'+
21117        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
21118        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
21119        '<div class="two-col">'+
21120        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
21121        '<div class="leg">'+
21122        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
21123        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
21124        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
21125        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
21126        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
21127        '<\/div>'+
21128        '<div class="two-col">'+
21129        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
21130        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
21131        '<\/div>'+
21132        '<script>'+ttJs+'<\/script>'+
21133        '<\/body><\/html>';
21134      slocDownload(html, fname, 'text/html;charset=utf-8;');
21135    }
21136    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
21137    // ── Inline delta charts ────────────────────────────────────────────────────
21138    var _icTT=document.getElementById('ic-tt');
21139    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
21140    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';};
21141    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
21142    (function(){
21143      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
21144      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21145      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();}
21146      function px(n){return Math.round(n);}
21147      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
21148      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
21149      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);});}
21150      var dr=getDeltaExportRows(),sd=_sd,lm={};
21151      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;});
21152      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
21153      // Chart 1: Baseline vs Current grouped bars
21154      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'}];
21155      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
21156      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;
21157      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21158      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"/>';}
21159      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
21160      c1mets.forEach(function(m,i){
21161        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
21162        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
21163        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>';
21164        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"/>';
21165        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>';
21166        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"/>';
21167        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>';
21168        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>';
21169        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>';
21170      });
21171      c1+='</svg>';
21172      // Chart 2: Delta by Metric
21173      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'}];
21174      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
21175      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;
21176      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21177      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21178      mets.forEach(function(m,i){
21179        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);
21180        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>';
21181        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
21182        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>';}
21183        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>';}
21184      });
21185      c2+='</svg>';
21186      // Chart 3: Language Code Delta
21187      var c3='';
21188      if(langs.length){
21189        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
21190        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;
21191        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
21192        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
21193        langs.forEach(function(l,i){
21194          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);
21195          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
21196          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"/>';
21197          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>';}
21198          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>';}
21199          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>';
21200        });
21201        c3+='</svg>';
21202      }
21203      // Chart 4: File Change Donut
21204      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;});
21205      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
21206      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;
21207      if(segs.length===1){
21208        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
21209        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
21210        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
21211      } else {
21212        segs.forEach(function(s){
21213          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21214          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);
21215          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);
21216          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"/>';
21217          ang+=sw;
21218        });
21219      }
21220      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>';
21221      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
21222      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>';});
21223      c4+='</svg>';
21224      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
21225      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
21226      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);}
21227      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
21228      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
21229      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='  /'+el.textContent.replace(/\s+/g,'');});
21230    })();
21231  </script>
21232  <script nonce="{{ csp_nonce }}">
21233  (function(){
21234    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'}];
21235    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);});}
21236    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21237    function init(){
21238      var btn=document.getElementById('settings-btn');if(!btn)return;
21239      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21240      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>';
21241      document.body.appendChild(m);
21242      var g=document.getElementById('scheme-grid');
21243      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);});
21244      var cl=document.getElementById('settings-close');
21245      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);
21246      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');});
21247      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21248      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21249    }
21250    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21251  }());
21252  </script>
21253  <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>
21254</body>
21255</html>
21256"##,
21257    ext = "html"
21258)]
21259// Template structs need many bool fields to pass Askama rendering flags.
21260#[allow(clippy::struct_excessive_bools)]
21261struct CompareTemplate {
21262    version: &'static str,
21263    project_label: String,
21264    baseline_git_commit: String,
21265    current_git_commit: String,
21266    baseline_run_id: String,
21267    current_run_id: String,
21268    baseline_run_id_short: String,
21269    current_run_id_short: String,
21270    baseline_timestamp: String,
21271    baseline_timestamp_utc_ms: i64,
21272    current_timestamp: String,
21273    current_timestamp_utc_ms: i64,
21274    project_path: String,
21275    baseline_code: u64,
21276    current_code: u64,
21277    code_lines_delta_str: String,
21278    code_lines_delta_class: String,
21279    baseline_files: u64,
21280    current_files: u64,
21281    files_analyzed_delta_str: String,
21282    files_analyzed_delta_class: String,
21283    baseline_comments: u64,
21284    current_comments: u64,
21285    comment_lines_delta_str: String,
21286    comment_lines_delta_class: String,
21287    code_lines_pct_str: String,
21288    files_analyzed_pct_str: String,
21289    comment_lines_pct_str: String,
21290    code_lines_added: i64,
21291    code_lines_removed: i64,
21292    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
21293    new_scope: bool,
21294    churn_rate_str: String,
21295    churn_rate_class: String,
21296    scope_flag: bool,
21297    files_added: usize,
21298    files_removed: usize,
21299    files_modified: usize,
21300    files_unchanged: usize,
21301    file_rows: Vec<CompareFileDeltaRow>,
21302    baseline_git_author: Option<String>,
21303    current_git_author: Option<String>,
21304    baseline_git_branch: String,
21305    current_git_branch: String,
21306    baseline_git_tags: Option<String>,
21307    current_git_tags: Option<String>,
21308    baseline_git_commit_date: Option<String>,
21309    current_git_commit_date: Option<String>,
21310    project_name: String,
21311    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
21312    submodule_options: Vec<String>,
21313    /// True when either run has submodule data — controls whether the scope bar is shown.
21314    has_any_submodule_data: bool,
21315    /// The submodule currently being compared, if the `sub` query param was provided.
21316    active_submodule: Option<String>,
21317    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
21318    super_scope_active: bool,
21319    csp_nonce: String,
21320    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
21321    coverage_delta_card: String,
21322}
21323
21324// ── LoginTemplate ──────────────────────────────────────────────────────────────
21325
21326#[derive(Template)]
21327#[template(
21328    source = r##"
21329<!doctype html>
21330<html lang="en">
21331<head>
21332  <meta charset="utf-8">
21333  <meta name="viewport" content="width=device-width, initial-scale=1">
21334  <title>OxideSLOC | Sign In</title>
21335  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21336  <style nonce="{{ csp_nonce }}">
21337    :root {
21338      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
21339      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
21340      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
21341      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
21342    }
21343    *{box-sizing:border-box;}
21344    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);}
21345    .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);}
21346    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
21347    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
21348    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
21349    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21350    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21351    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21352    .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;}
21353    @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));}}
21354    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
21355    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
21356    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21357    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
21358    .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;}
21359    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
21360    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;}
21361    input[type=password]:focus{border-color:var(--oxide);}
21362    .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;}
21363    .btn:hover{opacity:.88;}
21364    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
21365    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
21366  </style>
21367</head>
21368<body>
21369  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21377  </div>
21378  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21379<nav class="top-nav">
21380  <a class="brand" href="/">
21381    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21382    <span class="brand-title">OxideSLOC</span>
21383  </a>
21384</nav>
21385<main class="page">
21386  <div class="card">
21387    <h1>Sign In</h1>
21388    <p class="subtitle">Enter the API key printed when the server started.</p>
21389    {% if has_error %}
21390    <div class="error">Incorrect API key — please try again.</div>
21391    {% endif %}
21392    <form method="POST" action="/auth/login">
21393      <input type="hidden" name="next" value="{{ next_url|e }}">
21394      <label for="key">API Key</label>
21395      <input id="key" type="password" name="key" autocomplete="current-password"
21396             placeholder="Paste your API key here" autofocus>
21397      <button type="submit" class="btn">Sign In</button>
21398    </form>
21399    <p class="hint">
21400      The API key was printed in the terminal when the server started.<br>
21401      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
21402      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
21403    </p>
21404  </div>
21405</main>
21406<script nonce="{{ csp_nonce }}">
21407(function() {
21408  (function randomizeWatermarks() {
21409    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21410    if (!wms.length) return;
21411    var placed = [];
21412    function tooClose(top, left) {
21413      for (var i = 0; i < placed.length; i++) {
21414        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21415        if (dt < 16 && dl < 12) return true;
21416      }
21417      return false;
21418    }
21419    function pick(leftBand) {
21420      for (var attempt = 0; attempt < 50; attempt++) {
21421        var top = Math.random() * 88 + 2;
21422        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21423        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21424      }
21425      var top = Math.random() * 88 + 2;
21426      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21427      placed.push([top, left]); return [top, left];
21428    }
21429    var half = Math.floor(wms.length / 2);
21430    wms.forEach(function (img, i) {
21431      var pos = pick(i < half);
21432      var size = Math.floor(Math.random() * 100 + 120);
21433      var rot = (Math.random() * 360).toFixed(1);
21434      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21435      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;
21436    });
21437  })();
21438  (function spawnCodeParticles() {
21439    var container = document.getElementById('code-particles');
21440    if (!container) return;
21441    var snippets = [
21442      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21443      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21444      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21445      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21446      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21447    ];
21448    var count = 38;
21449    for (var i = 0; i < count; i++) {
21450      (function(idx) {
21451        var el = document.createElement('span');
21452        el.className = 'code-particle';
21453        el.textContent = snippets[idx % snippets.length];
21454        var left = Math.random() * 94 + 2;
21455        var top = Math.random() * 88 + 6;
21456        var dur = (Math.random() * 10 + 9).toFixed(1);
21457        var delay = (Math.random() * 18).toFixed(1);
21458        var rot = (Math.random() * 26 - 13).toFixed(1);
21459        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21460        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
21461        container.appendChild(el);
21462      })(i);
21463    }
21464  })();
21465})();
21466</script>
21467</body>
21468</html>
21469"##,
21470    ext = "html"
21471)]
21472pub(crate) struct LoginTemplate {
21473    pub(crate) csp_nonce: String,
21474    pub(crate) has_error: bool,
21475    pub(crate) next_url: String,
21476    pub(crate) lockout_threshold: u32,
21477}
21478
21479// ── REST API reference page ────────────────────────────────────────────────────
21480
21481#[derive(Template)]
21482#[template(
21483    source = r##"
21484<!doctype html>
21485<html lang="en">
21486<head>
21487  <meta charset="utf-8">
21488  <meta name="viewport" content="width=device-width, initial-scale=1">
21489  <title>OxideSLOC — REST API Reference</title>
21490  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21491  <style nonce="{{ csp_nonce }}">
21492    :root {
21493      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21494      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21495      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21496      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21497      --success:#16a34a;
21498    }
21499    body.dark-theme {
21500      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21501      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21502    }
21503    *{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;}
21504    .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);}
21505    .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;}
21506    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
21507    .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));}
21508    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21509    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21510    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
21511    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
21512    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21513    @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; } }
21514    .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;}
21515    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
21516    .nav-pill.active{background:rgba(255,255,255,0.22);}
21517    .nav-dropdown{position:relative;display:inline-flex;}
21518    .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;}
21519    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
21520    .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;}
21521    .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;}
21522    .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);}
21523    .nav-dropdown-menu a:last-child{border-bottom:none;}
21524    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
21525    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
21526    .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;}
21527    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21528    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21529    .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;}
21530    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21531    .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);}
21532    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
21533    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
21534    .settings-modal-body{padding:14px 16px 16px;}
21535    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21536    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21537    .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;}
21538    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21539    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21540    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21541    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21542    .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;}
21543    .tz-select:focus{border-color:var(--oxide);}
21544    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21545    .page-header{margin-bottom:28px;}
21546    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
21547    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
21548    .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;}
21549    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
21550    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
21551    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
21552    .callout strong{font-weight:800;}
21553    .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;}
21554    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
21555    .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;}
21556    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
21557    .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;}
21558    body.dark-theme .base-url-value{color:var(--accent);}
21559    .section{margin-bottom:36px;}
21560    .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);}
21561    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
21562    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
21563    .ep-header:hover{background:var(--surface-2);}
21564    .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;}
21565    .method.get{background:#dcfce7;color:#166534;}
21566    .method.post{background:#dbeafe;color:#1e40af;}
21567    .method.delete{background:#fee2e2;color:#991b1b;}
21568    body.dark-theme .method.get{background:#14532d;color:#86efac;}
21569    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
21570    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
21571    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
21572    .ep-path .param{color:var(--oxide-2);}
21573    body.dark-theme .ep-path .param{color:var(--oxide);}
21574    .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;}
21575    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
21576    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
21577    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
21578    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
21579    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
21580    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
21581    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
21582    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
21583    .ep-card.open .chevron{transform:rotate(180deg);}
21584    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
21585    .ep-card.open .ep-body{display:block;}
21586    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
21587    .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;}
21588    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
21589    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
21590    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21591    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
21592    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);}
21593    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
21594    table.params tr:last-child td{border-bottom:none;}
21595    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
21596    .pt-type{color:var(--muted-2);font-size:12px;}
21597    .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;}
21598    .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;}
21599    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
21600    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
21601    details.schema{margin-bottom:14px;}
21602    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;}
21603    details.schema summary:hover{color:var(--text);}
21604    .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;}
21605    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21606    .curl-wrap{position:relative;}
21607    .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;}
21608    .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;}
21609    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
21610    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
21611    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
21612    .webhook-note a{color:var(--accent-2);text-decoration:none;}
21613    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21614    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21615    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21616    .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;}
21617    @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));}}
21618    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21619    .site-footer a{color:var(--muted);}
21620  </style>
21621</head>
21622<body>
21623  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21631  </div>
21632  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21633  <div class="top-nav">
21634    <div class="top-nav-inner">
21635      <a class="brand" href="/">
21636        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21637        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
21638      </a>
21639      <div class="nav-right">
21640        <a class="nav-pill" href="/">Home</a>
21641        <div class="nav-dropdown">
21642          <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>
21643          <div class="nav-dropdown-menu">
21644            <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>
21645          </div>
21646        </div>
21647        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21648        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21649        <div class="nav-dropdown">
21650          <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>
21651          <div class="nav-dropdown-menu">
21652            <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>
21653          </div>
21654        </div>
21655        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21656          <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>
21657        </button>
21658        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21659          <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>
21660          <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>
21661        </button>
21662      </div>
21663    </div>
21664  </div>
21665
21666  <div class="page">
21667    <div class="page-header">
21668      <h1 class="page-title">REST API Reference</h1>
21669      <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>
21670    </div>
21671
21672    {% if has_api_key %}
21673    <div class="callout key-set">
21674      <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>
21675      <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>
21676    </div>
21677    {% else %}
21678    <div class="callout no-key">
21679      <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>
21680      <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>
21681    </div>
21682    {% endif %}
21683
21684    <div class="base-url-bar">
21685      <span class="base-url-label">Base URL</span>
21686      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
21687    </div>
21688
21689    <!-- Health -->
21690    <div class="section">
21691      <h2 class="section-title">Health &amp; Status</h2>
21692      <div class="ep-card">
21693        <div class="ep-header">
21694          <span class="method get">GET</span>
21695          <span class="ep-path">/healthz</span>
21696          <span class="auth-badge public">Public</span>
21697          <span class="ep-desc">Server liveness check</span>
21698          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21699        </div>
21700        <div class="ep-body">
21701          <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>
21702          <p class="params-heading">Response</p>
21703          <div class="schema-block">200 OK
21704Content-Type: text/plain
21705
21706ok</div>
21707          <p class="curl-heading">Example</p>
21708          <div class="curl-wrap">
21709            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
21710            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
21711          </div>
21712        </div>
21713      </div>
21714    </div>
21715
21716    <!-- Badges -->
21717    <div class="section">
21718      <h2 class="section-title">Badges</h2>
21719      <div class="ep-card">
21720        <div class="ep-header">
21721          <span class="method get">GET</span>
21722          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
21723          <span class="auth-badge public">Public</span>
21724          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
21725          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21726        </div>
21727        <div class="ep-body">
21728          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
21729          <p class="params-heading">Path Parameters</p>
21730          <table class="params">
21731            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21732            <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>
21733          </table>
21734          <p class="curl-heading">Example</p>
21735          <div class="curl-wrap">
21736            <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>
21737            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
21738          </div>
21739        </div>
21740      </div>
21741    </div>
21742
21743    <!-- Metrics -->
21744    <div class="section">
21745      <h2 class="section-title">Metrics</h2>
21746
21747      <div class="ep-card">
21748        <div class="ep-header">
21749          <span class="method get">GET</span>
21750          <span class="ep-path">/api/metrics/latest</span>
21751          <span class="auth-badge protected">Protected</span>
21752          <span class="ep-desc">Latest scan metrics (JSON)</span>
21753          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21754        </div>
21755        <div class="ep-body">
21756          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
21757          <details class="schema"><summary>Response schema</summary>
21758<div class="schema-block">{
21759  "run_id":    string,        // UUID
21760  "timestamp": string,        // ISO-8601 UTC
21761  "project":   string,        // scanned root path
21762  "summary": {
21763    "files_analyzed":       number,
21764    "files_skipped":        number,
21765    "code_lines":           number,
21766    "comment_lines":        number,
21767    "blank_lines":          number,
21768    "total_physical_lines": number,
21769    "functions":            number,
21770    "classes":              number,
21771    "variables":            number,
21772    "imports":              number
21773  },
21774  "languages": [
21775    { "name": string, "files": number, "code_lines": number,
21776      "comment_lines": number, "blank_lines": number,
21777      "functions": number, "classes": number,
21778      "variables": number, "imports": number }
21779  ]
21780}</div></details>
21781          <p class="curl-heading">Example</p>
21782          <div class="curl-wrap">
21783            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21784  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
21785            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
21786          </div>
21787        </div>
21788      </div>
21789
21790      <div class="ep-card">
21791        <div class="ep-header">
21792          <span class="method get">GET</span>
21793          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
21794          <span class="auth-badge protected">Protected</span>
21795          <span class="ep-desc">Metrics for a specific run</span>
21796          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21797        </div>
21798        <div class="ep-body">
21799          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
21800          <p class="params-heading">Path Parameters</p>
21801          <table class="params">
21802            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21803            <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>
21804          </table>
21805          <p class="curl-heading">Example</p>
21806          <div class="curl-wrap">
21807            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21808  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
21809            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
21810          </div>
21811        </div>
21812      </div>
21813
21814      <div class="ep-card">
21815        <div class="ep-header">
21816          <span class="method get">GET</span>
21817          <span class="ep-path">/api/metrics/history</span>
21818          <span class="auth-badge protected">Protected</span>
21819          <span class="ep-desc">Paginated scan history</span>
21820          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21821        </div>
21822        <div class="ep-body">
21823          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
21824          <p class="params-heading">Query Parameters</p>
21825          <table class="params">
21826            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21827            <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>
21828            <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>
21829          </table>
21830          <details class="schema"><summary>Response schema</summary>
21831<div class="schema-block">[{
21832  "run_id":         string,
21833  "timestamp":      string,   // ISO-8601 UTC
21834  "commit":         string | null,
21835  "branch":         string | null,
21836  "tags":           string[],
21837  "code_lines":     number,
21838  "comment_lines":  number,
21839  "blank_lines":    number,
21840  "physical_lines": number,
21841  "files_analyzed": number,
21842  "project_label":  string,
21843  "html_url":       string | null
21844}]</div></details>
21845          <p class="curl-heading">Example</p>
21846          <div class="curl-wrap">
21847            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21848  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
21849            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
21850          </div>
21851        </div>
21852      </div>
21853
21854      <div class="ep-card">
21855        <div class="ep-header">
21856          <span class="method get">GET</span>
21857          <span class="ep-path">/api/project-history</span>
21858          <span class="auth-badge protected">Protected</span>
21859          <span class="ep-desc">Project-level scan summary</span>
21860          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21861        </div>
21862        <div class="ep-body">
21863          <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>
21864          <p class="params-heading">Query Parameters</p>
21865          <table class="params">
21866            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21867            <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>
21868          </table>
21869          <details class="schema"><summary>Response schema</summary>
21870<div class="schema-block">{
21871  "scan_count":           number,
21872  "last_scan_id":         string | null,
21873  "last_scan_timestamp":  string | null,  // ISO-8601
21874  "last_scan_code_lines": number | null,
21875  "last_git_branch":      string | null,
21876  "last_git_commit":      string | null
21877}</div></details>
21878          <p class="curl-heading">Example</p>
21879          <div class="curl-wrap">
21880            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21881  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
21882            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
21883          </div>
21884        </div>
21885      </div>
21886
21887      <div class="ep-card">
21888        <div class="ep-header">
21889          <span class="method get">GET</span>
21890          <span class="ep-path">/api/metrics/submodules</span>
21891          <span class="auth-badge protected">Protected</span>
21892          <span class="ep-desc">List known git submodules across scans</span>
21893          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21894        </div>
21895        <div class="ep-body">
21896          <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>
21897          <p class="params-heading">Query Parameters</p>
21898          <table class="params">
21899            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21900            <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>
21901          </table>
21902          <details class="schema"><summary>Response schema</summary>
21903<div class="schema-block">[{
21904  "name":          string,  // submodule name
21905  "relative_path": string   // path relative to the project root
21906}]</div></details>
21907          <p class="curl-heading">Example</p>
21908          <div class="curl-wrap">
21909            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21910  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
21911            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
21912          </div>
21913        </div>
21914      </div>
21915    </div>
21916
21917    <!-- Async Run Status -->
21918    <div class="section">
21919      <h2 class="section-title">Async Run Status</h2>
21920
21921      <div class="ep-card">
21922        <div class="ep-header">
21923          <span class="method get">GET</span>
21924          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
21925          <span class="auth-badge protected">Protected</span>
21926          <span class="ep-desc">Poll scan completion</span>
21927          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21928        </div>
21929        <div class="ep-body">
21930          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
21931          <details class="schema"><summary>Response schema</summary>
21932<div class="schema-block">// Running
21933{ "state": "running",  "elapsed_secs": number }
21934
21935// Complete
21936{ "state": "complete", "run_id": string }
21937
21938// Failed
21939{ "state": "failed",   "message": string }</div></details>
21940          <p class="curl-heading">Example</p>
21941          <div class="curl-wrap">
21942            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21943  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
21944            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
21945          </div>
21946        </div>
21947      </div>
21948
21949      <div class="ep-card">
21950        <div class="ep-header">
21951          <span class="method get">GET</span>
21952          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
21953          <span class="auth-badge protected">Protected</span>
21954          <span class="ep-desc">Poll PDF generation readiness</span>
21955          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21956        </div>
21957        <div class="ep-body">
21958          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
21959          <details class="schema"><summary>Response schema</summary>
21960<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
21961          <p class="curl-heading">Example</p>
21962          <div class="curl-wrap">
21963            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21964  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
21965            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
21966          </div>
21967        </div>
21968      </div>
21969
21970      <div class="ep-card">
21971        <div class="ep-header">
21972          <span class="method post">POST</span>
21973          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
21974          <span class="auth-badge protected">Protected</span>
21975          <span class="ep-desc">Cancel a running scan</span>
21976          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21977        </div>
21978        <div class="ep-body">
21979          <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>
21980          <p class="curl-heading">Example</p>
21981          <div class="curl-wrap">
21982            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
21983  -H "Authorization: Bearer $SLOC_API_KEY" \
21984  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
21985            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
21986          </div>
21987        </div>
21988      </div>
21989    </div>
21990
21991    <!-- Scan Profiles -->
21992    <div class="section">
21993      <h2 class="section-title">Scan Profiles</h2>
21994
21995      <div class="ep-card">
21996        <div class="ep-header">
21997          <span class="method get">GET</span>
21998          <span class="ep-path">/api/scan-profiles</span>
21999          <span class="auth-badge protected">Protected</span>
22000          <span class="ep-desc">List saved scan profiles</span>
22001          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22002        </div>
22003        <div class="ep-body">
22004          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
22005          <details class="schema"><summary>Response schema</summary>
22006<div class="schema-block">{
22007  "profiles": [{
22008    "id":         string,   // UUID
22009    "name":       string,
22010    "created_at": string,   // ISO-8601
22011    "params":     object
22012  }]
22013}</div></details>
22014          <p class="curl-heading">Example</p>
22015          <div class="curl-wrap">
22016            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22017  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
22018            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
22019          </div>
22020        </div>
22021      </div>
22022
22023      <div class="ep-card">
22024        <div class="ep-header">
22025          <span class="method post">POST</span>
22026          <span class="ep-path">/api/scan-profiles</span>
22027          <span class="auth-badge protected">Protected</span>
22028          <span class="ep-desc">Save a scan profile</span>
22029          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22030        </div>
22031        <div class="ep-body">
22032          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
22033          <p class="params-heading">Request Body (application/json)</p>
22034          <table class="params">
22035            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22036            <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>
22037            <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>
22038          </table>
22039          <details class="schema"><summary>Response schema</summary>
22040<div class="schema-block">{ "ok": true }</div></details>
22041          <p class="curl-heading">Example</p>
22042          <div class="curl-wrap">
22043            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
22044  -H "Authorization: Bearer $SLOC_API_KEY" \
22045  -H "Content-Type: application/json" \
22046  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
22047  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
22048            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
22049          </div>
22050        </div>
22051      </div>
22052
22053      <div class="ep-card">
22054        <div class="ep-header">
22055          <span class="method delete">DELETE</span>
22056          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
22057          <span class="auth-badge protected">Protected</span>
22058          <span class="ep-desc">Delete a scan profile</span>
22059          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22060        </div>
22061        <div class="ep-body">
22062          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
22063          <p class="params-heading">Path Parameters</p>
22064          <table class="params">
22065            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22066            <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>
22067          </table>
22068          <details class="schema"><summary>Response schema</summary>
22069<div class="schema-block">{ "ok": true }</div></details>
22070          <p class="curl-heading">Example</p>
22071          <div class="curl-wrap">
22072            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
22073  -H "Authorization: Bearer $SLOC_API_KEY" \
22074  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
22075            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
22076          </div>
22077        </div>
22078      </div>
22079    </div>
22080
22081    <!-- Scheduled Scans -->
22082    <div class="section">
22083      <h2 class="section-title">Scheduled Scans</h2>
22084
22085      <div class="ep-card">
22086        <div class="ep-header">
22087          <span class="method get">GET</span>
22088          <span class="ep-path">/api/schedules</span>
22089          <span class="auth-badge protected">Protected</span>
22090          <span class="ep-desc">List configured schedules</span>
22091          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22092        </div>
22093        <div class="ep-body">
22094          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
22095          <p class="curl-heading">Example</p>
22096          <div class="curl-wrap">
22097            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22098  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22099            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
22100          </div>
22101        </div>
22102      </div>
22103
22104      <div class="ep-card">
22105        <div class="ep-header">
22106          <span class="method post">POST</span>
22107          <span class="ep-path">/api/schedules</span>
22108          <span class="auth-badge protected">Protected</span>
22109          <span class="ep-desc">Create a schedule</span>
22110          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22111        </div>
22112        <div class="ep-body">
22113          <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>
22114          <p class="curl-heading">Example</p>
22115          <div class="curl-wrap">
22116            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
22117  -H "Authorization: Bearer $SLOC_API_KEY" \
22118  -H "Content-Type: application/json" \
22119  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
22120  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22121            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
22122          </div>
22123        </div>
22124      </div>
22125
22126      <div class="ep-card">
22127        <div class="ep-header">
22128          <span class="method delete">DELETE</span>
22129          <span class="ep-path">/api/schedules</span>
22130          <span class="auth-badge protected">Protected</span>
22131          <span class="ep-desc">Delete a schedule</span>
22132          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22133        </div>
22134        <div class="ep-body">
22135          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
22136          <p class="curl-heading">Example</p>
22137          <div class="curl-wrap">
22138            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
22139  -H "Authorization: Bearer $SLOC_API_KEY" \
22140  -H "Content-Type: application/json" \
22141  -d '{"id":"&lt;schedule_id&gt;"}' \
22142  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
22143            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
22144          </div>
22145        </div>
22146      </div>
22147    </div>
22148
22149    <!-- Git Browser -->
22150    <div class="section">
22151      <h2 class="section-title">Git Browser</h2>
22152
22153      <div class="ep-card">
22154        <div class="ep-header">
22155          <span class="method get">GET</span>
22156          <span class="ep-path">/api/git/refs</span>
22157          <span class="auth-badge protected">Protected</span>
22158          <span class="ep-desc">List git refs for a repository</span>
22159          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22160        </div>
22161        <div class="ep-body">
22162          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
22163          <p class="params-heading">Query Parameters</p>
22164          <table class="params">
22165            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22166            <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>
22167          </table>
22168          <p class="curl-heading">Example</p>
22169          <div class="curl-wrap">
22170            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22171  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
22172            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
22173          </div>
22174        </div>
22175      </div>
22176
22177      <div class="ep-card">
22178        <div class="ep-header">
22179          <span class="method get">GET</span>
22180          <span class="ep-path">/api/git/scan-ref</span>
22181          <span class="auth-badge protected">Protected</span>
22182          <span class="ep-desc">SLOC-scan a specific git ref</span>
22183          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22184        </div>
22185        <div class="ep-body">
22186          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
22187          <p class="params-heading">Query Parameters</p>
22188          <table class="params">
22189            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22190            <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>
22191            <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>
22192          </table>
22193          <p class="curl-heading">Example</p>
22194          <div class="curl-wrap">
22195            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22196  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
22197            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
22198          </div>
22199        </div>
22200      </div>
22201
22202      <div class="ep-card">
22203        <div class="ep-header">
22204          <span class="method get">GET</span>
22205          <span class="ep-path">/api/git/compare-refs</span>
22206          <span class="auth-badge protected">Protected</span>
22207          <span class="ep-desc">Compare SLOC across two git refs</span>
22208          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22209        </div>
22210        <div class="ep-body">
22211          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
22212          <p class="params-heading">Query Parameters</p>
22213          <table class="params">
22214            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22215            <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>
22216            <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>
22217            <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>
22218          </table>
22219          <p class="curl-heading">Example</p>
22220          <div class="curl-wrap">
22221            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22222  "<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>
22223            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
22224          </div>
22225        </div>
22226      </div>
22227    </div>
22228
22229    <!-- Webhooks -->
22230    <div class="section">
22231      <h2 class="section-title">Webhooks</h2>
22232      <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>
22233
22234      <div class="ep-card">
22235        <div class="ep-header">
22236          <span class="method post">POST</span>
22237          <span class="ep-path">/webhooks/github</span>
22238          <span class="auth-badge hmac">HMAC</span>
22239          <span class="ep-desc">GitHub push event receiver</span>
22240          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22241        </div>
22242        <div class="ep-body">
22243          <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>
22244          <p class="params-heading">Required Headers</p>
22245          <table class="params">
22246            <tr><th>Header</th><th>Value</th></tr>
22247            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
22248            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
22249            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22250          </table>
22251        </div>
22252      </div>
22253
22254      <div class="ep-card">
22255        <div class="ep-header">
22256          <span class="method post">POST</span>
22257          <span class="ep-path">/webhooks/gitlab</span>
22258          <span class="auth-badge hmac">HMAC</span>
22259          <span class="ep-desc">GitLab push event receiver</span>
22260          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22261        </div>
22262        <div class="ep-body">
22263          <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>
22264          <p class="params-heading">Required Headers</p>
22265          <table class="params">
22266            <tr><th>Header</th><th>Value</th></tr>
22267            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
22268            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
22269            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22270          </table>
22271        </div>
22272      </div>
22273
22274      <div class="ep-card">
22275        <div class="ep-header">
22276          <span class="method post">POST</span>
22277          <span class="ep-path">/webhooks/bitbucket</span>
22278          <span class="auth-badge hmac">HMAC</span>
22279          <span class="ep-desc">Bitbucket push event receiver</span>
22280          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22281        </div>
22282        <div class="ep-body">
22283          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
22284          <p class="params-heading">Required Headers</p>
22285          <table class="params">
22286            <tr><th>Header</th><th>Value</th></tr>
22287            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
22288            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
22289          </table>
22290        </div>
22291      </div>
22292    </div>
22293
22294    <!-- Config -->
22295    <div class="section">
22296      <h2 class="section-title">Config Import / Export</h2>
22297
22298      <div class="ep-card">
22299        <div class="ep-header">
22300          <span class="method get">GET</span>
22301          <span class="ep-path">/export-config</span>
22302          <span class="auth-badge protected">Protected</span>
22303          <span class="ep-desc">Export server configuration as JSON</span>
22304          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22305        </div>
22306        <div class="ep-body">
22307          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
22308          <p class="curl-heading">Example</p>
22309          <div class="curl-wrap">
22310            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22311  -o config.json \
22312  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
22313            <button class="curl-copy-btn" data-target="c-export">Copy</button>
22314          </div>
22315        </div>
22316      </div>
22317
22318      <div class="ep-card">
22319        <div class="ep-header">
22320          <span class="method post">POST</span>
22321          <span class="ep-path">/import-config</span>
22322          <span class="auth-badge protected">Protected</span>
22323          <span class="ep-desc">Import server configuration</span>
22324          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22325        </div>
22326        <div class="ep-body">
22327          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
22328          <p class="curl-heading">Example</p>
22329          <div class="curl-wrap">
22330            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
22331  -H "Authorization: Bearer $SLOC_API_KEY" \
22332  -H "Content-Type: application/json" \
22333  -d @config.json \
22334  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
22335            <button class="curl-copy-btn" data-target="c-import">Copy</button>
22336          </div>
22337        </div>
22338      </div>
22339    </div>
22340
22341    <!-- CI Ingest -->
22342    <div class="section">
22343      <h2 class="section-title">CI Ingest</h2>
22344
22345      <div class="ep-card">
22346        <div class="ep-header">
22347          <span class="method post">POST</span>
22348          <span class="ep-path">/api/ingest</span>
22349          <span class="auth-badge protected">Protected</span>
22350          <span class="ep-desc">Push a pre-computed scan result from CI</span>
22351          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22352        </div>
22353        <div class="ep-body">
22354          <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>
22355          <p class="params-heading">Query Parameters</p>
22356          <table class="params">
22357            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22358            <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>
22359          </table>
22360          <p class="params-heading">Request Body (application/json)</p>
22361          <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>
22362          <details class="schema"><summary>Response schema</summary>
22363<div class="schema-block">// 201 Created
22364{
22365  "run_id":   string,  // UUID of the ingested run
22366  "view_url": string   // relative URL to the report page
22367}</div></details>
22368          <p class="curl-heading">Example</p>
22369          <div class="curl-wrap">
22370            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
22371  -H "Authorization: Bearer $SLOC_API_KEY" \
22372  -H "Content-Type: application/json" \
22373  -d @result.json \
22374  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
22375            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
22376          </div>
22377        </div>
22378      </div>
22379    </div>
22380
22381    <!-- Artifact Download -->
22382    <div class="section">
22383      <h2 class="section-title">Artifact Download</h2>
22384
22385      <div class="ep-card">
22386        <div class="ep-header">
22387          <span class="method get">GET</span>
22388          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
22389          <span class="auth-badge protected">Protected</span>
22390          <span class="ep-desc">Download or view a scan artifact</span>
22391          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22392        </div>
22393        <div class="ep-body">
22394          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
22395          <p class="params-heading">Path Parameters</p>
22396          <table class="params">
22397            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22398            <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>
22399            <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>
22400          </table>
22401          <p class="params-heading">Query Parameters</p>
22402          <table class="params">
22403            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22404            <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>
22405          </table>
22406          <p class="curl-heading">Example — download JSON result</p>
22407          <div class="curl-wrap">
22408            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22409  -o result.json \
22410  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
22411            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
22412          </div>
22413        </div>
22414      </div>
22415    </div>
22416
22417    <!-- Embed Widget -->
22418    <div class="section">
22419      <h2 class="section-title">Embed Widget</h2>
22420
22421      <div class="ep-card">
22422        <div class="ep-header">
22423          <span class="method get">GET</span>
22424          <span class="ep-path">/embed/summary</span>
22425          <span class="auth-badge protected">Protected</span>
22426          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
22427          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22428        </div>
22429        <div class="ep-body">
22430          <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>
22431          <p class="params-heading">Query Parameters</p>
22432          <table class="params">
22433            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22434            <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>
22435            <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>
22436          </table>
22437          <p class="curl-heading">Example</p>
22438          <div class="curl-wrap">
22439            <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"
22440        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
22441            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
22442          </div>
22443        </div>
22444      </div>
22445    </div>
22446
22447    <!-- Confluence Integration -->
22448    <div class="section">
22449      <h2 class="section-title">Confluence Integration</h2>
22450
22451      <div class="ep-card">
22452        <div class="ep-header">
22453          <span class="method get">GET</span>
22454          <span class="ep-path">/api/confluence/config</span>
22455          <span class="auth-badge protected">Protected</span>
22456          <span class="ep-desc">Get current Confluence configuration</span>
22457          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22458        </div>
22459        <div class="ep-body">
22460          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
22461          <details class="schema"><summary>Response schema</summary>
22462<div class="schema-block">{
22463  "configured":     boolean,
22464  "tier":           "cloud" | "server",
22465  "base_url":       string,
22466  "username":       string,
22467  "api_token_set":  boolean,
22468  "space_key":      string,
22469  "parent_page_id": string | null,
22470  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
22471}</div></details>
22472          <p class="curl-heading">Example</p>
22473          <div class="curl-wrap">
22474            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22475  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22476            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
22477          </div>
22478        </div>
22479      </div>
22480
22481      <div class="ep-card">
22482        <div class="ep-header">
22483          <span class="method post">POST</span>
22484          <span class="ep-path">/api/confluence/config</span>
22485          <span class="auth-badge protected">Protected</span>
22486          <span class="ep-desc">Save Confluence configuration</span>
22487          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22488        </div>
22489        <div class="ep-body">
22490          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
22491          <p class="params-heading">Request Body (application/json)</p>
22492          <table class="params">
22493            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22494            <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>
22495            <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>
22496            <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>
22497            <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>
22498            <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>
22499            <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>
22500            <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>
22501          </table>
22502          <details class="schema"><summary>Response schema</summary>
22503<div class="schema-block">{ "ok": true }</div></details>
22504          <p class="curl-heading">Example</p>
22505          <div class="curl-wrap">
22506            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
22507  -H "Authorization: Bearer $SLOC_API_KEY" \
22508  -H "Content-Type: application/json" \
22509  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
22510  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22511            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
22512          </div>
22513        </div>
22514      </div>
22515
22516      <div class="ep-card">
22517        <div class="ep-header">
22518          <span class="method post">POST</span>
22519          <span class="ep-path">/api/confluence/test</span>
22520          <span class="auth-badge protected">Protected</span>
22521          <span class="ep-desc">Test Confluence connection</span>
22522          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22523        </div>
22524        <div class="ep-body">
22525          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
22526          <details class="schema"><summary>Response schema</summary>
22527<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
22528          <p class="curl-heading">Example</p>
22529          <div class="curl-wrap">
22530            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
22531  -H "Authorization: Bearer $SLOC_API_KEY" \
22532  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
22533            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
22534          </div>
22535        </div>
22536      </div>
22537
22538      <div class="ep-card">
22539        <div class="ep-header">
22540          <span class="method post">POST</span>
22541          <span class="ep-path">/api/confluence/post</span>
22542          <span class="auth-badge protected">Protected</span>
22543          <span class="ep-desc">Publish a scan report to Confluence</span>
22544          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22545        </div>
22546        <div class="ep-body">
22547          <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>
22548          <p class="params-heading">Request Body (application/json)</p>
22549          <table class="params">
22550            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22551            <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>
22552            <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>
22553            <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>
22554          </table>
22555          <details class="schema"><summary>Response schema</summary>
22556<div class="schema-block">// 200 OK
22557{ "ok": true, "page_id": string }
22558
22559// 400 / 502 on error
22560{ "ok": false, "error": string }</div></details>
22561          <p class="curl-heading">Example</p>
22562          <div class="curl-wrap">
22563            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
22564  -H "Authorization: Bearer $SLOC_API_KEY" \
22565  -H "Content-Type: application/json" \
22566  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
22567  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
22568            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
22569          </div>
22570        </div>
22571      </div>
22572
22573      <div class="ep-card">
22574        <div class="ep-header">
22575          <span class="method get">GET</span>
22576          <span class="ep-path">/api/confluence/wiki-markup</span>
22577          <span class="auth-badge protected">Protected</span>
22578          <span class="ep-desc">Get Confluence wiki markup for a run</span>
22579          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22580        </div>
22581        <div class="ep-body">
22582          <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>
22583          <p class="params-heading">Query Parameters</p>
22584          <table class="params">
22585            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22586            <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>
22587          </table>
22588          <p class="curl-heading">Example</p>
22589          <div class="curl-wrap">
22590            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22591  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
22592            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
22593          </div>
22594        </div>
22595      </div>
22596    </div>
22597
22598    <!-- Authentication -->
22599    <div class="section">
22600      <h2 class="section-title">Authentication</h2>
22601      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
22602
22603      <div class="ep-card">
22604        <div class="ep-header">
22605          <span class="method get">GET</span>
22606          <span class="ep-path">/auth/login</span>
22607          <span class="auth-badge public">Public</span>
22608          <span class="ep-desc">Login page</span>
22609          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22610        </div>
22611        <div class="ep-body">
22612          <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>
22613          <p class="params-heading">Query Parameters</p>
22614          <table class="params">
22615            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22616            <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>
22617            <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>
22618          </table>
22619        </div>
22620      </div>
22621
22622      <div class="ep-card">
22623        <div class="ep-header">
22624          <span class="method post">POST</span>
22625          <span class="ep-path">/auth/login</span>
22626          <span class="auth-badge public">Public</span>
22627          <span class="ep-desc">Submit credentials and get a session cookie</span>
22628          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22629        </div>
22630        <div class="ep-body">
22631          <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>
22632          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
22633          <table class="params">
22634            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22635            <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>
22636            <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>
22637          </table>
22638          <p class="curl-heading">Example</p>
22639          <div class="curl-wrap">
22640            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
22641  -d "key=$SLOC_API_KEY&amp;next=/" \
22642  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
22643            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
22644          </div>
22645        </div>
22646      </div>
22647    </div>
22648
22649    <!-- Coverage Suggestion -->
22650    <div class="section">
22651      <h2 class="section-title">Coverage Suggestion</h2>
22652
22653      <div class="ep-card">
22654        <div class="ep-header">
22655          <span class="method get">GET</span>
22656          <span class="ep-path">/api/suggest-coverage</span>
22657          <span class="auth-badge protected">Protected</span>
22658          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
22659          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22660        </div>
22661        <div class="ep-body">
22662          <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>
22663          <p class="params-heading">Query Parameters</p>
22664          <table class="params">
22665            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22666            <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>
22667          </table>
22668          <details class="schema"><summary>Response schema</summary>
22669<div class="schema-block">{
22670  "found": string | null,  // absolute path to the coverage file, if detected
22671  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
22672  "hint":  string | null   // shell command to generate coverage if not found
22673}</div></details>
22674          <p class="curl-heading">Example</p>
22675          <div class="curl-wrap">
22676            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22677  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
22678            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
22679          </div>
22680        </div>
22681      </div>
22682    </div>
22683
22684  </div>
22685
22686  <footer class="site-footer">
22687    local code analysis - metrics, history and reports
22688    &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>
22689    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22690    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22691    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22692    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22693  </footer>
22694
22695  <script nonce="{{ csp_nonce }}">
22696    (function () {
22697      var base = window.location.origin;
22698      document.getElementById('base-url').textContent = base;
22699      document.querySelectorAll('.base-url-slot').forEach(function (el) {
22700        el.textContent = base;
22701      });
22702
22703      document.querySelectorAll('.ep-header').forEach(function (hdr) {
22704        hdr.addEventListener('click', function () {
22705          hdr.closest('.ep-card').classList.toggle('open');
22706        });
22707      });
22708
22709      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
22710        btn.addEventListener('click', function () {
22711          var targetId = btn.dataset.target;
22712          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
22713          if (!pre) return;
22714          navigator.clipboard.writeText(pre.textContent).then(function () {
22715            btn.textContent = 'Copied!';
22716            btn.classList.add('copied');
22717            setTimeout(function () {
22718              btn.textContent = 'Copy';
22719              btn.classList.remove('copied');
22720            }, 2000);
22721          });
22722        });
22723      });
22724
22725      var storageKey = 'oxide-sloc-theme';
22726      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
22727      var themeBtn = document.getElementById('theme-toggle');
22728      if (themeBtn) {
22729        themeBtn.addEventListener('click', function () {
22730          var dark = document.body.classList.toggle('dark-theme');
22731          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
22732        });
22733      }
22734      (function() {
22735        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'}];
22736        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);});}
22737        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22738        var btn=document.getElementById('settings-btn');if(!btn)return;
22739        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22740        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>';
22741        document.body.appendChild(m);
22742        var g=document.getElementById('scheme-grid');
22743        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);});
22744        var cl=document.getElementById('settings-close');
22745        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);
22746        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');});
22747        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22748        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22749      })();
22750      (function randomizeWatermarks() {
22751        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22752        if (!wms.length) return;
22753        var placed = [];
22754        function tooClose(top, left) {
22755          for (var i = 0; i < placed.length; i++) {
22756            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22757            if (dt < 16 && dl < 12) return true;
22758          }
22759          return false;
22760        }
22761        function pick(leftBand) {
22762          for (var attempt = 0; attempt < 50; attempt++) {
22763            var top = Math.random() * 88 + 2;
22764            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22765            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22766          }
22767          var top = Math.random() * 88 + 2;
22768          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22769          placed.push([top, left]); return [top, left];
22770        }
22771        var half = Math.floor(wms.length / 2);
22772        wms.forEach(function (img, i) {
22773          var pos = pick(i < half);
22774          var size = Math.floor(Math.random() * 100 + 120);
22775          var rot = (Math.random() * 360).toFixed(1);
22776          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22777          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;
22778        });
22779      })();
22780      (function spawnCodeParticles() {
22781        var container = document.getElementById('code-particles');
22782        if (!container) return;
22783        var snippets = [
22784          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22785          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22786          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22787          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22788          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22789        ];
22790        var count = 38;
22791        for (var i = 0; i < count; i++) {
22792          (function(idx) {
22793            var el = document.createElement('span');
22794            el.className = 'code-particle';
22795            el.textContent = snippets[idx % snippets.length];
22796            var left = Math.random() * 94 + 2;
22797            var top = Math.random() * 88 + 6;
22798            var dur = (Math.random() * 10 + 9).toFixed(1);
22799            var delay = (Math.random() * 18).toFixed(1);
22800            var rot = (Math.random() * 26 - 13).toFixed(1);
22801            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22802            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
22803            container.appendChild(el);
22804          })(i);
22805        }
22806      })();
22807    }());
22808  </script>
22809</body>
22810</html>
22811"##,
22812    ext = "html"
22813)]
22814struct ApiDocsTemplate {
22815    has_api_key: bool,
22816    csp_nonce: String,
22817    version: &'static str,
22818}