Skip to main content

sloc_web/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod auth;
26pub(crate) mod confluence;
27pub(crate) mod error;
28pub(crate) mod git_browser;
29pub(crate) mod git_webhook;
30pub(crate) mod integrations;
31
32use std::{
33    collections::{HashMap, VecDeque},
34    fmt::Write,
35    fs,
36    net::{IpAddr, SocketAddr},
37    path::{Path, PathBuf},
38    process::Stdio,
39    sync::Arc,
40    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
41};
42
43use anyhow::{Context, Result};
44use askama::Template;
45use axum::{
46    body::Body,
47    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
48    http::{header, HeaderValue, Request, StatusCode},
49    middleware::{self, Next},
50    response::{Html, IntoResponse, Response},
51    routing::{get, post},
52    Json, Router,
53};
54use serde::{Deserialize, Serialize};
55use tokio::sync::Mutex;
56use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
57
58use sloc_config::{
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");
68
69use sloc_core::{
70    analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
71    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
72};
73use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html, write_pdf_from_run};
74const MAX_CONCURRENT_ANALYSES: usize = 4;
75
76/// Windows-only helpers that force the native file-picker dialog into the
77/// foreground instead of appearing minimised behind other windows.
78///
79/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
80/// foreground thread so that windows created on our thread inherit focus; and
81/// (b) spin a polling watcher that finds the dialog by title and calls
82/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
83#[cfg(all(target_os = "windows", feature = "native-dialog"))]
84#[allow(clippy::upper_case_acronyms)]
85mod win_dialog_focus {
86    use std::mem::size_of;
87
88    type HWND = *mut core::ffi::c_void;
89    type DWORD = u32;
90    type UINT = u32;
91    type BOOL = i32;
92
93    // Mirror of FLASHWINFO from winuser.h — field names kept in PascalCase to
94    // match the Win32 ABI layout exactly; the #[allow] suppresses the Rust
95    // naming lint for this one struct.
96    #[repr(C)]
97    #[allow(non_snake_case)]
98    struct FLASHWINFO {
99        cbSize: UINT,
100        hwnd: HWND,
101        dwFlags: DWORD,
102        uCount: UINT,
103        dwTimeout: DWORD,
104    }
105
106    const FLASHW_ALL: DWORD = 0x3;
107    const FLASHW_TIMERNOFG: DWORD = 0xC;
108
109    #[link(name = "user32")]
110    extern "system" {
111        fn GetForegroundWindow() -> HWND;
112        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
113        fn BringWindowToTop(hWnd: HWND) -> BOOL;
114        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
115        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
116        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
117        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
118    }
119
120    #[link(name = "kernel32")]
121    extern "system" {
122        fn GetCurrentThreadId() -> DWORD;
123    }
124
125    /// Attaches our thread's input to the foreground window's thread so that
126    /// windows created on our thread inherit foreground focus.  Returns the
127    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
128    /// the thread was already the foreground thread.
129    pub fn attach_to_foreground() -> DWORD {
130        unsafe {
131            let fg_hwnd = GetForegroundWindow();
132            if fg_hwnd.is_null() {
133                return 0;
134            }
135            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
136            let my_tid = GetCurrentThreadId();
137            if fg_tid == my_tid {
138                return 0;
139            }
140            AttachThreadInput(my_tid, fg_tid, 1);
141            fg_tid
142        }
143    }
144
145    /// Undoes `attach_to_foreground`.
146    pub fn detach_from_foreground(fg_tid: DWORD) {
147        if fg_tid == 0 {
148            return;
149        }
150        unsafe {
151            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
152        }
153    }
154
155    /// Spawns a short-lived watcher thread that polls for a dialog window
156    /// matching `title` and, once found, forces it to the foreground and
157    /// flashes its taskbar button until the user interacts with it.
158    pub fn flash_dialog_when_ready(title: String) {
159        std::thread::spawn(move || {
160            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
161            for _ in 0..40 {
162                std::thread::sleep(std::time::Duration::from_millis(80));
163                unsafe {
164                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
165                    if !hwnd.is_null() {
166                        SetForegroundWindow(hwnd);
167                        BringWindowToTop(hwnd);
168                        #[allow(non_snake_case)]
169                        FlashWindowEx(&FLASHWINFO {
170                            // size_of returns usize; Win32 struct field is u32 (UINT).
171                            // struct size fits trivially within u32.
172                            #[allow(clippy::cast_possible_truncation)]
173                            cbSize: size_of::<FLASHWINFO>() as UINT,
174                            hwnd,
175                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
176                            uCount: 3,
177                            dwTimeout: 0,
178                        });
179                        break;
180                    }
181                }
182            }
183        });
184    }
185}
186
187/// Sliding-window rate limiter keyed by client IP.
188/// Uses only std primitives — no external crate required.
189pub(crate) struct IpRateLimiter {
190    window: Duration,
191    max_requests: usize,
192    pub(crate) auth_lockout_threshold: u32,
193    auth_lockout_window: Duration,
194    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
195    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
196}
197
198impl IpRateLimiter {
199    pub(crate) fn new(
200        window: Duration,
201        max_requests: usize,
202        auth_lockout_threshold: u32,
203        auth_lockout_window: Duration,
204    ) -> Self {
205        Self {
206            window,
207            max_requests,
208            auth_lockout_threshold,
209            auth_lockout_window,
210            state: std::sync::Mutex::new(HashMap::new()),
211            auth_failures: std::sync::Mutex::new(HashMap::new()),
212        }
213    }
214
215    // The MutexGuard `state` must live as long as `bucket` borrows from it,
216    // so it cannot be dropped any earlier than the end of the inner block.
217    #[allow(clippy::significant_drop_tightening)]
218    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
219        let now = Instant::now();
220        let cutoff = now.checked_sub(self.window).unwrap_or(now);
221        let mut state = self
222            .state
223            .lock()
224            .unwrap_or_else(std::sync::PoisonError::into_inner);
225        if state.len() > 10_000 {
226            state.retain(|_, bucket| {
227                while bucket.front().is_some_and(|t| *t <= cutoff) {
228                    bucket.pop_front();
229                }
230                !bucket.is_empty()
231            });
232        }
233        let bucket = state.entry(ip).or_default();
234        while bucket.front().is_some_and(|t| *t <= cutoff) {
235            bucket.pop_front();
236        }
237        if bucket.len() >= self.max_requests {
238            false
239        } else {
240            bucket.push_back(now);
241            true
242        }
243    }
244
245    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
246        let now = Instant::now();
247        let mut map = self
248            .auth_failures
249            .lock()
250            .unwrap_or_else(std::sync::PoisonError::into_inner);
251        map.entry(ip)
252            .and_modify(|e| {
253                e.0 += 1;
254                e.1 = now;
255            })
256            .or_insert_with(|| (1, now));
257    }
258
259    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
260        let mut map = self
261            .auth_failures
262            .lock()
263            .unwrap_or_else(std::sync::PoisonError::into_inner);
264        let expired = map
265            .get(&ip)
266            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
267        if expired {
268            map.remove(&ip);
269            return false;
270        }
271        map.get(&ip)
272            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
273    }
274
275    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
276        let map = self
277            .auth_failures
278            .lock()
279            .unwrap_or_else(std::sync::PoisonError::into_inner);
280        map.get(&ip).map_or(0, |e| {
281            self.auth_lockout_window
282                .checked_sub(e.1.elapsed())
283                .map_or(0, |r| r.as_secs())
284        })
285    }
286
287    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
288        tokio::spawn(async move {
289            let mut interval = tokio::time::interval(Duration::from_mins(1));
290            interval.tick().await; // consume the immediate first tick
291            loop {
292                interval.tick().await;
293                let now = Instant::now();
294                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
295                {
296                    let mut state = limiter
297                        .state
298                        .lock()
299                        .unwrap_or_else(std::sync::PoisonError::into_inner);
300                    state.retain(|_, bucket| {
301                        while bucket.front().is_some_and(|t| *t <= cutoff) {
302                            bucket.pop_front();
303                        }
304                        !bucket.is_empty()
305                    });
306                }
307                {
308                    let mut auth = limiter
309                        .auth_failures
310                        .lock()
311                        .unwrap_or_else(std::sync::PoisonError::into_inner);
312                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
313                }
314            }
315        });
316    }
317}
318
319/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
320/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
321/// files but never triggers a scan.
322fn spawn_upload_staging_cleanup() {
323    tokio::spawn(async move {
324        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
325            .ok()
326            .and_then(|v| v.parse().ok())
327            .unwrap_or(4);
328        let ttl_secs = ttl_hours * 3600;
329        let mut interval = tokio::time::interval(Duration::from_hours(1));
330        interval.tick().await; // consume the immediate first tick
331        loop {
332            interval.tick().await;
333            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
334            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
335                continue;
336            };
337            while let Ok(Some(entry)) = dir.next_entry().await {
338                let path = entry.path();
339                let age_secs = tokio::fs::metadata(&path)
340                    .await
341                    .ok()
342                    .and_then(|m| m.modified().ok())
343                    .and_then(|t| t.elapsed().ok())
344                    .map_or(0, |d| d.as_secs());
345                if age_secs > ttl_secs {
346                    tracing::debug!(
347                        event = "upload_staging_cleanup",
348                        path = %path.display(),
349                        age_secs,
350                        "removing stale upload staging directory"
351                    );
352                    let _ = tokio::fs::remove_dir_all(&path).await;
353                }
354            }
355        }
356    });
357}
358
359/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
360#[derive(Clone, Debug, Default)]
361struct RunResultContext {
362    prev_entry: Option<RegistryEntry>,
363    prev_scan_count: usize,
364    project_path: String,
365}
366
367/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
368#[derive(Clone)]
369enum AsyncRunState {
370    Running {
371        started_at: std::time::Instant,
372        cancel_token: Arc<std::sync::atomic::AtomicBool>,
373    },
374    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
375    Complete {
376        run_id: String,
377    },
378    Failed {
379        message: String,
380    },
381    Cancelled,
382}
383
384/// A saved scan configuration profile — stores the form parameters so users can
385/// re-run a favourite scan with one click.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387struct ScanProfile {
388    id: String,
389    name: String,
390    created_at: String,
391    /// The raw scan-form parameters serialized as JSON.
392    params: serde_json::Value,
393}
394
395#[derive(Debug, Clone, Default, Serialize, Deserialize)]
396struct ScanProfileStore {
397    profiles: Vec<ScanProfile>,
398}
399
400impl ScanProfileStore {
401    fn load(path: &std::path::Path) -> Self {
402        fs::read_to_string(path)
403            .ok()
404            .and_then(|s| serde_json::from_str(&s).ok())
405            .unwrap_or_default()
406    }
407
408    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
409        if let Some(parent) = path.parent() {
410            fs::create_dir_all(parent)?;
411        }
412        let json = serde_json::to_string_pretty(self)?;
413        fs::write(path, json)?;
414        Ok(())
415    }
416}
417
418#[derive(Clone)]
419pub(crate) struct AppState {
420    pub(crate) base_config: AppConfig,
421    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
422    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
423    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
424    pub(crate) registry_path: PathBuf,
425    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
426    pub(crate) server_mode: bool,
427    pub(crate) tls_enabled: bool,
428    pub(crate) api_keys: Vec<secrecy::Secret<String>>,
429    pub(crate) rate_limiter: Arc<IpRateLimiter>,
430    pub(crate) trust_proxy: bool,
431    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
432    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
433    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
434    /// Directory where remote repositories are cloned for git-browser scans.
435    pub(crate) git_clones_dir: PathBuf,
436    /// Persisted list of webhook / poll schedules.
437    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
438    pub(crate) schedules_path: PathBuf,
439    /// Named scan profiles saved by the user via the web UI.
440    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
441    pub(crate) scan_profiles_path: PathBuf,
442    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
443    /// Persisted Confluence integration settings.
444    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
445    pub(crate) confluence_path: PathBuf,
446    /// Directories the user has pinned for auto-scanning of external reports.
447    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
448    pub(crate) watched_dirs_path: PathBuf,
449}
450
451type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
452
453/// Parameters for the fire-and-forget HTML + PDF background task.
454
455#[derive(Clone, Debug)]
456pub(crate) struct RunArtifacts {
457    output_dir: PathBuf,
458    html_path: Option<PathBuf>,
459    pdf_path: Option<PathBuf>,
460    json_path: Option<PathBuf>,
461    csv_path: Option<PathBuf>,
462    xlsx_path: Option<PathBuf>,
463    scan_config_path: Option<PathBuf>,
464    report_title: String,
465    result_context: RunResultContext,
466}
467
468#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
469fn build_router(state: AppState) -> Router {
470    let protected = Router::new()
471        .route("/", get(splash))
472        .route("/scan-setup", get(scan_setup_handler))
473        .route("/scan", get(index))
474        .route("/analyze", post(analyze_handler))
475        .route("/preview", get(preview_handler))
476        .route("/api/suggest-coverage", get(api_suggest_coverage))
477        .route("/pick-directory", get(pick_directory_handler))
478        .route("/open-path", get(open_path_handler))
479        .route("/pick-file", get(pick_file_handler))
480        .route(
481            "/api/upload-directory",
482            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
483        )
484        .route(
485            "/api/upload-file",
486            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
487        )
488        .route(
489            "/api/upload-tarball",
490            post(upload_tarball_handler).layer(DefaultBodyLimit::disable()),
491        )
492        .route("/locate-report", post(locate_report_handler))
493        .route("/locate-reports-dir", post(locate_reports_dir_handler))
494        .route("/relocate-scan", post(relocate_scan_handler))
495        .route("/watched-dirs/add", post(add_watched_dir_handler))
496        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
497        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
498        .route("/view-reports", get(history_handler))
499        .route("/compare-scans", get(compare_select_handler))
500        .route("/compare", get(compare_handler))
501        .route("/images/{folder}/{file}", get(image_handler))
502        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
503        .route("/api/metrics/latest", get(api_metrics_latest_handler))
504        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
505        .route("/api/metrics/history", get(api_metrics_history_handler))
506        .route(
507            "/api/metrics/submodules",
508            get(api_metrics_submodules_handler),
509        )
510        .route("/api/ingest", post(api_ingest_handler))
511        .route("/api/project-history", get(project_history_handler))
512        .route("/trend-reports", get(trend_report_handler))
513        .route("/test-metrics", get(test_metrics_handler))
514        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
515        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
516        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
517        .route("/runs/result/{run_id}", get(async_run_result_handler))
518        .route("/embed/summary", get(embed_handler))
519        // ── Git browser ────────────────────────────────────────────────────────
520        .route("/git-browser", get(git_browser::git_browser_handler))
521        .route("/api/git/refs", get(git_browser::api_list_refs))
522        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
523        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
524        // ── Config export / import ─────────────────────────────────────────────
525        .route("/export-config", get(export_config_handler))
526        .route("/import-config", post(import_config_handler))
527        // ── Scan profiles ──────────────────────────────────────────────────────
528        .route("/api/scan-profiles", get(api_list_scan_profiles))
529        .route("/api/scan-profiles", post(api_save_scan_profile))
530        .route(
531            "/api/scan-profiles/{id}",
532            axum::routing::delete(api_delete_scan_profile),
533        )
534        // ── Integrations (webhooks + Confluence) ──────────────────────────────
535        .route("/integrations", get(integrations::integrations_handler))
536        .route(
537            "/webhook-setup",
538            get(|| async { axum::response::Redirect::permanent("/integrations") }),
539        )
540        .route(
541            "/confluence-setup",
542            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
543        )
544        .route("/api/schedules", get(git_webhook::api_list_schedules))
545        .route("/api/schedules", post(git_webhook::api_create_schedule))
546        .route(
547            "/api/schedules",
548            axum::routing::delete(git_webhook::api_delete_schedule),
549        )
550        .route(
551            "/api/confluence/config",
552            get(confluence::api_get_confluence_config),
553        )
554        .route(
555            "/api/confluence/config",
556            post(confluence::api_save_confluence_config),
557        )
558        .route(
559            "/api/confluence/test",
560            post(confluence::api_test_confluence),
561        )
562        .route(
563            "/api/confluence/post",
564            post(confluence::api_post_to_confluence),
565        )
566        .route(
567            "/api/confluence/wiki-markup",
568            get(confluence::api_wiki_markup),
569        )
570        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
571        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
572        .route(
573            "/api/runs/{run_id}",
574            axum::routing::delete(delete_run_handler),
575        )
576        .route("/api/runs/cleanup", post(cleanup_runs_handler))
577        // ── REST API reference page ────────────────────────────────────────────
578        .route("/api-docs", get(api_docs_handler))
579        .route_layer(middleware::from_fn_with_state(
580            state.clone(),
581            auth::require_api_key,
582        ));
583
584    protected
585        .route("/healthz", get(healthz))
586        .route("/api/health", get(healthz))
587        .route("/api/version", get(api_version_handler))
588        .route("/api/openapi.yaml", get(openapi_yaml_handler))
589        .route("/badge/{metric}", get(badge_handler))
590        .route("/static/chart.js", get(chart_js_handler))
591        .route("/auth/login", get(auth::auth_login_get))
592        .route("/auth/login", post(auth::auth_login_post))
593        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
594        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
595        .route(
596            "/webhooks/github",
597            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
598        )
599        .route(
600            "/webhooks/gitlab",
601            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
602        )
603        .route(
604            "/webhooks/bitbucket",
605            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
606        )
607        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
608        .layer(middleware::from_fn_with_state(
609            state.clone(),
610            add_security_headers,
611        ))
612        .layer(build_cors_layer(state.server_mode))
613        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
614        .with_state(state)
615}
616
617/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
618pub fn make_test_router() -> Router {
619    let tmp = std::env::temp_dir().join("sloc_test");
620    let state = AppState {
621        base_config: AppConfig::default(),
622        artifacts: Arc::new(Mutex::new(HashMap::new())),
623        async_runs: Arc::new(Mutex::new(HashMap::new())),
624        registry: Arc::new(Mutex::new(ScanRegistry::default())),
625        registry_path: tmp.join("registry.json"),
626        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
627        server_mode: false,
628        tls_enabled: false,
629        api_keys: vec![],
630        rate_limiter: Arc::new(IpRateLimiter::new(
631            Duration::from_mins(1),
632            600,
633            10,
634            Duration::from_hours(1),
635        )),
636        trust_proxy: false,
637        trusted_proxy_ips: vec![],
638        git_clones_dir: tmp.join("git-clones"),
639        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
640        schedules_path: tmp.join("schedules.json"),
641        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
642        scan_profiles_path: tmp.join("scan_profiles.json"),
643        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
644        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
645        confluence_path: tmp.join("confluence_config.json"),
646        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
647        watched_dirs_path: tmp.join("watched_dirs.json"),
648    };
649    build_router(state)
650}
651
652/// Test router with one API key pre-loaded. Used by auth integration tests.
653pub fn make_test_router_with_key(api_key: &str) -> Router {
654    let tmp = std::env::temp_dir().join("sloc_test_key");
655    let state = AppState {
656        base_config: AppConfig::default(),
657        artifacts: Arc::new(Mutex::new(HashMap::new())),
658        async_runs: Arc::new(Mutex::new(HashMap::new())),
659        registry: Arc::new(Mutex::new(ScanRegistry::default())),
660        registry_path: tmp.join("registry.json"),
661        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
662        server_mode: false,
663        tls_enabled: false,
664        api_keys: vec![secrecy::Secret::new(api_key.to_owned())],
665        rate_limiter: Arc::new(IpRateLimiter::new(
666            Duration::from_mins(1),
667            600,
668            10,
669            Duration::from_hours(1),
670        )),
671        trust_proxy: false,
672        trusted_proxy_ips: vec![],
673        git_clones_dir: tmp.join("git-clones"),
674        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
675        schedules_path: tmp.join("schedules.json"),
676        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
677        scan_profiles_path: tmp.join("scan_profiles.json"),
678        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
679        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
680        confluence_path: tmp.join("confluence_config.json"),
681        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
682        watched_dirs_path: tmp.join("watched_dirs.json"),
683    };
684    build_router(state)
685}
686
687struct RuntimeSecurityConfig {
688    api_keys: Vec<secrecy::Secret<String>>,
689    tls_cert: Option<String>,
690    tls_key: Option<String>,
691    tls_enabled: bool,
692    trust_proxy: bool,
693    trusted_proxy_ips: Vec<IpAddr>,
694    rate_limiter: Arc<IpRateLimiter>,
695}
696
697fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
698    let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
699        .or_else(|_| std::env::var("SLOC_API_KEY"))
700        .unwrap_or_default()
701        .split(',')
702        .map(str::trim)
703        .filter(|s| !s.is_empty())
704        .map(|s| secrecy::Secret::new(s.to_owned()))
705        .collect();
706    if server_mode && api_keys.is_empty() {
707        println!(
708            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
709             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
710        );
711    }
712    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
713    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
714    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
715    if server_mode && !tls_enabled {
716        println!(
717            "WARNING: TLS is not configured. Traffic is cleartext. \
718             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
719             or terminate TLS at a reverse proxy (nginx, caddy)."
720        );
721    }
722    if server_mode {
723        println!(
724            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
725             to restrict cross-origin access (comma-separated)."
726        );
727    }
728    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
729    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
730        .unwrap_or_default()
731        .split(',')
732        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
733        .collect();
734    if trust_proxy {
735        if trusted_proxy_ips.is_empty() {
736            println!(
737                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
738                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
739                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
740            );
741        } else {
742            println!(
743                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
744                trusted_proxy_ips
745                    .iter()
746                    .map(std::string::ToString::to_string)
747                    .collect::<Vec<_>>()
748                    .join(", ")
749            );
750        }
751    } else if server_mode {
752        println!(
753            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
754             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
755             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
756             enable per-client rate limiting via X-Forwarded-For."
757        );
758    }
759    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
760        println!(
761            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
762             DISABLED for all git operations. Remove this variable before production use."
763        );
764    }
765    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
766        .ok()
767        .and_then(|v| v.parse::<u32>().ok())
768        .unwrap_or(10);
769    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
770        .ok()
771        .and_then(|v| v.parse::<u64>().ok())
772        .unwrap_or(3600);
773    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
774    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
775    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
776    let default_rpm: usize = if server_mode { 120 } else { 600 };
777    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
778        .ok()
779        .and_then(|v| v.parse::<usize>().ok())
780        .unwrap_or(default_rpm);
781    let rate_limiter = Arc::new(IpRateLimiter::new(
782        Duration::from_mins(1),
783        rate_limit_rpm,
784        auth_lockout_threshold,
785        Duration::from_secs(auth_lockout_secs),
786    ));
787    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
788    RuntimeSecurityConfig {
789        api_keys,
790        tls_cert,
791        tls_key,
792        tls_enabled,
793        trust_proxy,
794        trusted_proxy_ips,
795        rate_limiter,
796    }
797}
798
799/// # Errors
800///
801/// Returns an error if the server fails to bind to the configured address or
802/// if the TLS configuration cannot be loaded.
803///
804/// # Panics
805///
806/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
807#[allow(clippy::too_many_lines)]
808pub async fn serve(config: AppConfig) -> Result<()> {
809    let bind_address = config.web.bind_address.clone();
810    let server_mode = config.web.server_mode;
811    let output_root = resolve_output_root(None);
812    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
813    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
814        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
815    let mut registry = ScanRegistry::load(&registry_path);
816    registry.prune_stale();
817    let _ = registry.save(&registry_path);
818
819    let sec = load_runtime_security_config(server_mode);
820    spawn_upload_staging_cleanup();
821
822    let git_clones_dir = resolve_git_clones_dir(&output_root);
823    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
824        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
825    let schedules = ScheduleStore::load(&schedules_path);
826    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
827        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
828    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
829    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
830        |_| output_root.join("confluence_config.json"),
831        PathBuf::from,
832    );
833    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
834    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
835        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
836    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
837
838    let state = AppState {
839        base_config: config,
840        artifacts: Arc::new(Mutex::new(HashMap::new())),
841        async_runs: Arc::new(Mutex::new(HashMap::new())),
842        registry: Arc::new(Mutex::new(registry)),
843        registry_path,
844        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
845        server_mode,
846        tls_enabled: sec.tls_enabled,
847        api_keys: sec.api_keys,
848        rate_limiter: sec.rate_limiter,
849        trust_proxy: sec.trust_proxy,
850        trusted_proxy_ips: sec.trusted_proxy_ips,
851        git_clones_dir,
852        schedules: Arc::new(Mutex::new(schedules)),
853        schedules_path,
854        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
855        scan_profiles_path,
856        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
857        confluence: Arc::new(Mutex::new(confluence)),
858        confluence_path,
859        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
860        watched_dirs_path,
861    };
862
863    restart_poll_schedules(&state).await;
864
865    let app = build_router(state.clone());
866
867    // Try the configured port first, then step up through a few alternatives.
868    // On Windows, a killed process can leave its LISTEN socket as an unkillable
869    // kernel zombie (visible in netstat but owned by no living process).  Rather
870    // than failing, we auto-select the next free port and tell the user.
871    let preferred: SocketAddr = bind_address
872        .parse()
873        .with_context(|| format!("invalid bind address: {bind_address}"))?;
874    let (listener, addr) = {
875        let candidates = (0u16..=9).map(|offset| {
876            let mut a = preferred;
877            a.set_port(preferred.port().saturating_add(offset));
878            a
879        });
880        let mut found = None;
881        for candidate in candidates {
882            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
883                found = Some((l, candidate));
884                break;
885            }
886        }
887        found.ok_or_else(|| {
888            anyhow::anyhow!(
889                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
890                bind_address,
891                preferred.port(),
892                preferred.port().saturating_add(9)
893            )
894        })?
895    };
896    if addr != preferred {
897        eprintln!(
898            "NOTE: port {} is blocked by a system socket (Windows zombie); \
899             using {} instead.",
900            preferred.port(),
901            addr.port()
902        );
903    }
904
905    if sec.tls_enabled {
906        let cert_path = sec
907            .tls_cert
908            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
909        let key_path = sec
910            .tls_key
911            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
912        let tls_config = build_tls_config(&cert_path, &key_path)
913            .context("failed to load TLS certificate/key")?;
914        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
915
916        let url = format!("https://{addr}/");
917        println!("OxideSLOC server running at {url} (TLS)");
918        println!("Use Ctrl+C to stop.");
919
920        return serve_tls(listener, app, acceptor, server_mode).await;
921    }
922
923    let url = format!("http://{addr}/");
924    log_startup_url(&url, server_mode);
925
926    axum::serve(
927        listener,
928        app.into_make_service_with_connect_info::<SocketAddr>(),
929    )
930    .with_graceful_shutdown(shutdown_signal(server_mode))
931    .await
932    .context("web server terminated unexpectedly")
933}
934
935/// Discover the primary non-loopback IPv4 address by asking the OS which
936/// outbound interface it would use to reach a public address.  No packets are
937/// sent — the UDP socket is only used to query the routing table.
938fn primary_lan_ip() -> Option<String> {
939    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
940    socket.connect("8.8.8.8:80").ok()?;
941    let addr = socket.local_addr().ok()?;
942    let ip = addr.ip();
943    if ip.is_loopback() {
944        return None;
945    }
946    Some(ip.to_string())
947}
948
949/// Print the startup URL and, in local mode, open the browser and schedule it.
950fn log_startup_url(url: &str, server_mode: bool) {
951    if server_mode {
952        println!("OxideSLOC server running at {url}");
953        println!("Use Ctrl+C to stop.");
954    } else {
955        println!("OxideSLOC local web UI running at {url}");
956        println!("Press Ctrl+C to stop the server.");
957        let open_url = url.to_owned();
958        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
959    }
960}
961
962/// Open the given URL in the default system browser.
963fn open_browser_tab(url: &str) {
964    #[cfg(target_os = "windows")]
965    let _ = std::process::Command::new("cmd")
966        .args(["/c", "start", "", url])
967        .stdout(Stdio::null())
968        .stderr(Stdio::null())
969        .spawn();
970    #[cfg(target_os = "macos")]
971    let _ = std::process::Command::new("open")
972        .arg(url)
973        .stdout(Stdio::null())
974        .stderr(Stdio::null())
975        .spawn();
976    #[cfg(target_os = "linux")]
977    let _ = std::process::Command::new("xdg-open")
978        .arg(url)
979        .stdout(Stdio::null())
980        .stderr(Stdio::null())
981        .spawn();
982}
983
984/// Graceful-shutdown future: resolves on Ctrl-C.
985async fn shutdown_signal(server_mode: bool) {
986    if tokio::signal::ctrl_c().await.is_ok() {
987        println!();
988        if server_mode {
989            println!("Shutting down OxideSLOC server...");
990        } else {
991            println!("Shutting down OxideSLOC local web UI...");
992        }
993        println!("Server stopped cleanly.");
994    }
995}
996
997/// Load a rustls `ServerConfig` from PEM certificate and key files.
998fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
999    use rustls_pemfile::{certs, private_key};
1000    use std::io::BufReader;
1001
1002    let cert_bytes =
1003        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1004    let key_bytes =
1005        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1006
1007    let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
1008        .collect::<std::result::Result<_, _>>()
1009        .context("failed to parse TLS certificates")?;
1010
1011    let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
1012        .context("failed to parse TLS private key")?
1013        .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
1014
1015    rustls::ServerConfig::builder()
1016        .with_no_client_auth()
1017        .with_single_cert(cert_chain, key)
1018        .context("failed to build TLS server config")
1019}
1020
1021/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1022async fn serve_tls(
1023    listener: tokio::net::TcpListener,
1024    app: Router,
1025    acceptor: tokio_rustls::TlsAcceptor,
1026    server_mode: bool,
1027) -> Result<()> {
1028    use hyper_util::rt::{TokioExecutor, TokioIo};
1029    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1030    use hyper_util::service::TowerToHyperService;
1031    use tower::{Service, ServiceExt};
1032
1033    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1034
1035    loop {
1036        tokio::select! {
1037            biased;
1038            _ = tokio::signal::ctrl_c() => {
1039                println!();
1040                if server_mode {
1041                    println!("Shutting down OxideSLOC server...");
1042                } else {
1043                    println!("Shutting down OxideSLOC local web UI...");
1044                }
1045                println!("Server stopped cleanly.");
1046                return Ok(());
1047            }
1048            result = listener.accept() => {
1049                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1050                let acceptor = acceptor.clone();
1051                let mut factory = make_svc.clone();
1052
1053                tokio::spawn(async move {
1054                    let tls = match acceptor.accept(tcp).await {
1055                        Ok(s) => s,
1056                        Err(e) => {
1057                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1058                            return;
1059                        }
1060                    };
1061                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1062                        Ok(f) => match Service::call(f, peer_addr).await {
1063                            Ok(s) => s,
1064                            Err(_) => return,
1065                        },
1066                        Err(_) => return,
1067                    };
1068                    let io = TokioIo::new(tls);
1069                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1070                        .serve_connection(io, TowerToHyperService::new(svc))
1071                        .await
1072                    {
1073                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1074                    }
1075                });
1076            }
1077        }
1078    }
1079}
1080
1081// auth moved to auth.rs
1082
1083fn build_cors_layer(server_mode: bool) -> CorsLayer {
1084    if server_mode {
1085        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1086            .unwrap_or_default()
1087            .split(',')
1088            .filter(|s| !s.is_empty())
1089            .filter_map(|s| s.trim().parse().ok())
1090            .collect();
1091        if allowed.is_empty() {
1092            return CorsLayer::new();
1093        }
1094        CorsLayer::new()
1095            .allow_origin(AllowOrigin::list(allowed))
1096            .allow_methods(AllowMethods::list([
1097                axum::http::Method::GET,
1098                axum::http::Method::POST,
1099            ]))
1100            .allow_headers(AllowHeaders::list([
1101                axum::http::header::AUTHORIZATION,
1102                axum::http::header::CONTENT_TYPE,
1103            ]))
1104    } else {
1105        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1106            let s = origin.to_str().unwrap_or("");
1107            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1108        }))
1109    }
1110}
1111
1112async fn add_security_headers(
1113    State(state): State<AppState>,
1114    mut req: Request<Body>,
1115    next: Next,
1116) -> Response {
1117    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1118    req.extensions_mut().insert(CspNonce(nonce.clone()));
1119    let mut resp = next.run(req).await;
1120    let h = resp.headers_mut();
1121    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1122    h.insert(
1123        "X-Content-Type-Options",
1124        HeaderValue::from_static("nosniff"),
1125    );
1126    h.insert(
1127        "Referrer-Policy",
1128        HeaderValue::from_static("strict-origin-when-cross-origin"),
1129    );
1130    let csp = format!(
1131        "default-src 'self'; \
1132         style-src 'self' 'unsafe-inline'; \
1133         img-src 'self' data: blob:; \
1134         script-src 'self' 'nonce-{nonce}'; \
1135         font-src 'self' data:; \
1136         object-src 'none'; \
1137         frame-ancestors 'none'"
1138    );
1139    h.insert(
1140        "Content-Security-Policy",
1141        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1142            HeaderValue::from_static(
1143                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1144            )
1145        }),
1146    );
1147    h.insert(
1148        "X-Permitted-Cross-Domain-Policies",
1149        HeaderValue::from_static("none"),
1150    );
1151    h.insert(
1152        "Permissions-Policy",
1153        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1154    );
1155    h.insert(
1156        "Cross-Origin-Opener-Policy",
1157        HeaderValue::from_static("same-origin"),
1158    );
1159    h.insert(
1160        "Cross-Origin-Resource-Policy",
1161        HeaderValue::from_static("same-origin"),
1162    );
1163    if state.tls_enabled {
1164        h.insert(
1165            "Strict-Transport-Security",
1166            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1167        );
1168    }
1169    resp
1170}
1171
1172async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1173    let peer_ip = req
1174        .extensions()
1175        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1176        .map(|c| c.0.ip());
1177
1178    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1179    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1180    // header spoofing from direct connections.
1181    let ip = peer_ip
1182        .and_then(|peer| {
1183            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1184                req.headers()
1185                    .get("X-Forwarded-For")
1186                    .and_then(|v| v.to_str().ok())
1187                    .and_then(|s| s.split(',').next())
1188                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1189            } else {
1190                None
1191            }
1192        })
1193        .or(peer_ip)
1194        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1195
1196    if !state.rate_limiter.is_allowed(ip) {
1197        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1198            path = %req.uri().path(), "Rate limit exceeded");
1199        return (
1200            StatusCode::TOO_MANY_REQUESTS,
1201            [(header::RETRY_AFTER, "60")],
1202            "429 Too Many Requests\n",
1203        )
1204            .into_response();
1205    }
1206    next.run(req).await
1207}
1208
1209async fn splash(
1210    State(state): State<AppState>,
1211    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1212) -> impl IntoResponse {
1213    let lan_ip = if state.server_mode {
1214        primary_lan_ip()
1215    } else {
1216        None
1217    };
1218    let port = state
1219        .base_config
1220        .web
1221        .bind_address
1222        .rsplit(':')
1223        .next()
1224        .and_then(|p| p.parse::<u16>().ok())
1225        .unwrap_or(4317);
1226    let has_api_key = !state.api_keys.is_empty();
1227    let template = SplashTemplate {
1228        csp_nonce,
1229        server_mode: state.server_mode,
1230        lan_ip,
1231        port,
1232        version: env!("CARGO_PKG_VERSION"),
1233        has_api_key,
1234    };
1235    Html(
1236        template
1237            .render()
1238            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1239    )
1240}
1241
1242async fn index(
1243    State(state): State<AppState>,
1244    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1245    Query(query): Query<IndexQuery>,
1246) -> impl IntoResponse {
1247    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1248        let policy = query
1249            .mixed_line_policy
1250            .unwrap_or_else(|| "code_only".to_string());
1251        let behavior = query
1252            .binary_file_behavior
1253            .unwrap_or_else(|| "skip".to_string());
1254        let cfg = ScanConfig {
1255            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1256            path: query.path.unwrap_or_default(),
1257            include_globs: query.include_globs.unwrap_or_default(),
1258            exclude_globs: query.exclude_globs.unwrap_or_default(),
1259            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1260            mixed_line_policy: policy,
1261            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1262                != Some("off"),
1263            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1264            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1265            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1266                != Some("disabled"),
1267            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1268            binary_file_behavior: behavior,
1269            output_dir: query.output_dir.unwrap_or_default(),
1270            report_title: query.report_title.unwrap_or_default(),
1271            generate_html: query.generate_html.as_deref() != Some("off"),
1272            generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1273        };
1274        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1275    } else {
1276        "{}".to_string()
1277    };
1278
1279    let git_repo = query.git_repo.unwrap_or_default();
1280    let git_ref = query.git_ref.unwrap_or_default();
1281
1282    let git_label = make_git_label(&git_repo, &git_ref);
1283    let git_output_dir = if git_label.is_empty() {
1284        String::new()
1285    } else {
1286        desktop_dir().join(&git_label).display().to_string()
1287    };
1288    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1289    let git_output_dir_json =
1290        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1291
1292    let template = IndexTemplate {
1293        version: env!("CARGO_PKG_VERSION"),
1294        prefill_json,
1295        csp_nonce,
1296        git_repo,
1297        git_ref,
1298        git_label_json,
1299        git_output_dir_json,
1300        server_mode: state.server_mode,
1301    };
1302
1303    Html(
1304        template
1305            .render()
1306            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1307    )
1308}
1309
1310async fn scan_setup_handler(
1311    State(state): State<AppState>,
1312    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1313) -> impl IntoResponse {
1314    let recent_scans_json = {
1315        let arr: Vec<serde_json::Value> = {
1316            let reg = state.registry.lock().await;
1317            reg.entries
1318                .iter()
1319                .rev()
1320                .take(6)
1321                .map(|e| {
1322                    let run_dir = e
1323                        .html_path
1324                        .as_ref()
1325                        .or(e.json_path.as_ref())
1326                        .and_then(|p| p.parent().map(PathBuf::from));
1327                    let config_val: Option<serde_json::Value> = run_dir
1328                        .and_then(|d| find_scan_config_in_dir(&d))
1329                        .and_then(|p| fs::read_to_string(&p).ok())
1330                        .and_then(|s| serde_json::from_str(&s).ok());
1331                    serde_json::json!({
1332                        "project_label": e.project_label,
1333                        "timestamp": fmt_la_time(e.timestamp_utc),
1334                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1335                        "config": config_val,
1336                    })
1337                })
1338                .collect()
1339        };
1340        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1341    };
1342
1343    let template = ScanSetupTemplate {
1344        version: env!("CARGO_PKG_VERSION"),
1345        recent_scans_json,
1346        csp_nonce,
1347    };
1348    Html(
1349        template
1350            .render()
1351            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1352    )
1353}
1354
1355async fn healthz() -> &'static str {
1356    "ok"
1357}
1358
1359async fn api_version_handler() -> impl IntoResponse {
1360    axum::Json(serde_json::json!({
1361        "name": "oxide-sloc",
1362        "version": env!("CARGO_PKG_VERSION"),
1363    }))
1364}
1365
1366static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1367
1368async fn openapi_yaml_handler() -> impl IntoResponse {
1369    (
1370        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1371        OPENAPI_YAML,
1372    )
1373}
1374
1375async fn api_docs_handler(
1376    State(state): State<AppState>,
1377    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1378) -> impl IntoResponse {
1379    let has_api_key = !state.api_keys.is_empty();
1380    Html(
1381        ApiDocsTemplate {
1382            has_api_key,
1383            csp_nonce,
1384            version: env!("CARGO_PKG_VERSION"),
1385        }
1386        .render()
1387        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1388    )
1389}
1390
1391async fn chart_js_handler() -> impl IntoResponse {
1392    (
1393        [(
1394            header::CONTENT_TYPE,
1395            "application/javascript; charset=utf-8",
1396        )],
1397        CHART_JS,
1398    )
1399}
1400
1401#[derive(Debug, Deserialize)]
1402struct AnalyzeForm {
1403    path: String,
1404    git_repo: Option<String>,
1405    git_ref: Option<String>,
1406    mixed_line_policy: Option<MixedLinePolicy>,
1407    python_docstrings_as_comments: Option<String>,
1408    generated_file_detection: Option<String>,
1409    minified_file_detection: Option<String>,
1410    vendor_directory_detection: Option<String>,
1411    include_lockfiles: Option<String>,
1412    binary_file_behavior: Option<BinaryFileBehavior>,
1413    output_dir: Option<String>,
1414    report_title: Option<String>,
1415    report_header_footer: Option<String>,
1416    generate_html: Option<String>,
1417    generate_pdf: Option<String>,
1418    include_globs: Option<String>,
1419    exclude_globs: Option<String>,
1420    submodule_breakdown: Option<String>,
1421    coverage_file: Option<String>,
1422    continuation_line_policy: Option<ContinuationLinePolicy>,
1423    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1424    count_compiler_directives: Option<String>,
1425}
1426
1427#[allow(clippy::struct_excessive_bools)]
1428#[derive(Debug, Serialize, Deserialize, Clone)]
1429struct ScanConfig {
1430    oxide_sloc_version: String,
1431    path: String,
1432    include_globs: String,
1433    exclude_globs: String,
1434    submodule_breakdown: bool,
1435    mixed_line_policy: String,
1436    python_docstrings_as_comments: bool,
1437    generated_file_detection: bool,
1438    minified_file_detection: bool,
1439    vendor_directory_detection: bool,
1440    include_lockfiles: bool,
1441    binary_file_behavior: String,
1442    output_dir: String,
1443    report_title: String,
1444    generate_html: bool,
1445    generate_pdf: bool,
1446}
1447
1448#[derive(Debug, Deserialize, Default)]
1449struct IndexQuery {
1450    path: Option<String>,
1451    include_globs: Option<String>,
1452    exclude_globs: Option<String>,
1453    submodule_breakdown: Option<String>,
1454    mixed_line_policy: Option<String>,
1455    python_docstrings_as_comments: Option<String>,
1456    generated_file_detection: Option<String>,
1457    minified_file_detection: Option<String>,
1458    vendor_directory_detection: Option<String>,
1459    include_lockfiles: Option<String>,
1460    binary_file_behavior: Option<String>,
1461    output_dir: Option<String>,
1462    report_title: Option<String>,
1463    generate_html: Option<String>,
1464    generate_pdf: Option<String>,
1465    prefilled: Option<String>,
1466    git_repo: Option<String>,
1467    git_ref: Option<String>,
1468}
1469
1470#[derive(Debug, Deserialize)]
1471struct PreviewQuery {
1472    path: Option<String>,
1473    include_globs: Option<String>,
1474    exclude_globs: Option<String>,
1475}
1476
1477#[cfg(feature = "native-dialog")]
1478#[derive(Debug, Deserialize)]
1479struct PickDirectoryQuery {
1480    kind: Option<String>,
1481    current: Option<String>,
1482}
1483
1484#[cfg(not(feature = "native-dialog"))]
1485#[derive(Debug, Deserialize)]
1486struct PickDirectoryQuery {}
1487
1488#[derive(Debug, Deserialize, Default)]
1489struct ArtifactQuery {
1490    download: Option<String>,
1491}
1492
1493#[cfg(feature = "native-dialog")]
1494#[derive(Debug, Serialize)]
1495struct PickDirectoryResponse {
1496    selected_path: Option<String>,
1497    cancelled: bool,
1498}
1499
1500#[cfg(feature = "native-dialog")]
1501async fn pick_directory_handler(
1502    State(state): State<AppState>,
1503    Query(query): Query<PickDirectoryQuery>,
1504) -> Response {
1505    if state.server_mode {
1506        return StatusCode::NOT_FOUND.into_response();
1507    }
1508
1509    let is_coverage = query.kind.as_deref() == Some("coverage");
1510    let title = match query.kind.as_deref() {
1511        Some("output") => "Select output directory",
1512        Some("reports") => "Select folder containing saved reports",
1513        Some("coverage") => "Select LCOV coverage file",
1514        _ => "Select project directory",
1515    }
1516    .to_owned();
1517    let current = query.current.clone();
1518
1519    let picked = tokio::task::spawn_blocking(move || {
1520        // Windows: attach to the foreground thread so the dialog inherits focus,
1521        // and kick off a watcher that flashes the dialog once it appears.
1522        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1523        let fg_tid = win_dialog_focus::attach_to_foreground();
1524        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1525        win_dialog_focus::flash_dialog_when_ready(title.clone());
1526
1527        let mut dialog = rfd::FileDialog::new().set_title(&title);
1528        if let Some(current) = current.as_deref() {
1529            let resolved = resolve_input_path(current);
1530            let seed = if resolved.is_dir() {
1531                Some(resolved)
1532            } else {
1533                resolved.parent().map(Path::to_path_buf)
1534            };
1535            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1536                dialog = dialog.set_directory(seed_dir);
1537            }
1538        }
1539        let result = if is_coverage {
1540            dialog
1541                .add_filter(
1542                    "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1543                    &["info", "lcov", "xml"],
1544                )
1545                .pick_file()
1546        } else {
1547            dialog.pick_folder()
1548        };
1549
1550        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1551        win_dialog_focus::detach_from_foreground(fg_tid);
1552
1553        result
1554    })
1555    .await
1556    .unwrap_or(None);
1557
1558    Json(PickDirectoryResponse {
1559        selected_path: picked.as_ref().map(|p| display_path(p)),
1560        cancelled: picked.is_none(),
1561    })
1562    .into_response()
1563}
1564
1565#[cfg(not(feature = "native-dialog"))]
1566async fn pick_directory_handler(
1567    State(_state): State<AppState>,
1568    Query(_query): Query<PickDirectoryQuery>,
1569) -> Response {
1570    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1571}
1572
1573#[cfg(feature = "native-dialog")]
1574async fn pick_file_handler(State(state): State<AppState>) -> Response {
1575    if state.server_mode {
1576        return StatusCode::NOT_FOUND.into_response();
1577    }
1578    let picked = tokio::task::spawn_blocking(|| {
1579        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1580        let fg_tid = win_dialog_focus::attach_to_foreground();
1581        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1582        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1583
1584        let result = rfd::FileDialog::new()
1585            .set_title("Select HTML report")
1586            .add_filter("HTML report", &["html"])
1587            .pick_file();
1588
1589        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1590        win_dialog_focus::detach_from_foreground(fg_tid);
1591
1592        result
1593    })
1594    .await
1595    .unwrap_or(None);
1596    Json(PickDirectoryResponse {
1597        selected_path: picked.as_ref().map(|p| display_path(p)),
1598        cancelled: picked.is_none(),
1599    })
1600    .into_response()
1601}
1602
1603#[cfg(not(feature = "native-dialog"))]
1604async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1605    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1606}
1607
1608// ── Browser-upload handlers (server mode only) ────────────────────────────────
1609
1610/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
1611/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
1612fn is_upload_tmp_path(path: &Path) -> bool {
1613    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
1614    path.starts_with(&upload_root)
1615}
1616
1617/// Returns true when `path` is the built-in sample or test-fixture directory.
1618/// These paths ship with the server binary and are always safe to scan/preview.
1619fn is_sample_path(path: &Path) -> bool {
1620    let root = workspace_root();
1621    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
1622}
1623
1624/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
1625fn upload_base_dir() -> PathBuf {
1626    std::env::temp_dir().join("oxide-sloc-uploads")
1627}
1628
1629/// Returns the staging path for a given upload id inside the base dir.
1630fn upload_staging_path(id: &str) -> PathBuf {
1631    upload_base_dir().join(id)
1632}
1633
1634/// Validate basic field constraints on a directory-upload request.
1635/// Returns an error `Response` if the request should be rejected immediately.
1636#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
1637fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
1638    const MAX_FILES: usize = 50_000;
1639    if body.files.is_empty() {
1640        return Err((
1641            StatusCode::BAD_REQUEST,
1642            Json(serde_json::json!({"error": "No files received"})),
1643        )
1644            .into_response());
1645    }
1646    if body.files.len() > MAX_FILES {
1647        return Err((
1648            StatusCode::PAYLOAD_TOO_LARGE,
1649            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
1650        )
1651            .into_response());
1652    }
1653    Ok(())
1654}
1655
1656/// Resolve or create the staging directory for a directory upload.
1657/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
1658fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
1659    match id {
1660        Some(id)
1661            if !id.is_empty()
1662                && id.len() <= 36
1663                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
1664        {
1665            (id.to_string(), upload_staging_path(id))
1666        }
1667        _ => {
1668            let new_id = uuid::Uuid::new_v4().to_string();
1669            let staging = upload_staging_path(&new_id);
1670            (new_id, staging)
1671        }
1672    }
1673}
1674
1675/// Decode, size-check, and write one uploaded file entry into `staging`.
1676/// Returns `Ok(())` whether the file was written or skipped (bad base64).
1677/// Returns `Err(Response)` for fatal errors; the caller is responsible for
1678/// cleaning up `staging` before propagating the error.
1679#[allow(clippy::result_large_err)]
1680async fn stage_decoded_entry(
1681    entry: &UploadedFile,
1682    staging: &Path,
1683    total_bytes: &mut usize,
1684    project_root: &mut Option<PathBuf>,
1685) -> Result<(), Response> {
1686    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
1687
1688    let Ok(data) = base64::Engine::decode(
1689        &base64::engine::general_purpose::STANDARD,
1690        entry.content.as_bytes(),
1691    ) else {
1692        return Ok(());
1693    };
1694
1695    *total_bytes += data.len();
1696    if *total_bytes > MAX_TOTAL_BYTES {
1697        return Err((
1698            StatusCode::PAYLOAD_TOO_LARGE,
1699            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
1700        )
1701            .into_response());
1702    }
1703
1704    let rel = std::path::Path::new(&entry.path);
1705    if project_root.is_none() {
1706        if let Some(first) = rel.components().next() {
1707            *project_root = Some(staging.join(first.as_os_str()));
1708        }
1709    }
1710
1711    let dest = staging.join(rel);
1712    if let Some(parent) = dest.parent() {
1713        if tokio::fs::create_dir_all(parent).await.is_err() {
1714            return Err((
1715                StatusCode::INTERNAL_SERVER_ERROR,
1716                Json(serde_json::json!({"error": "Failed to create directory structure"})),
1717            )
1718                .into_response());
1719        }
1720    }
1721
1722    if tokio::fs::write(&dest, &data).await.is_err() {
1723        return Err((
1724            StatusCode::INTERNAL_SERVER_ERROR,
1725            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
1726        )
1727            .into_response());
1728    }
1729
1730    Ok(())
1731}
1732
1733/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
1734/// and path-traversal guard. Returns `(file_count, project_root)` on success or
1735/// an error `Response` on failure (staging dir is cleaned up before returning).
1736async fn write_upload_files(
1737    files: &[UploadedFile],
1738    staging: &Path,
1739    upload_id: &str,
1740) -> Result<(usize, Option<PathBuf>), Response> {
1741    let mut total_bytes: usize = 0;
1742    let mut project_root: Option<PathBuf> = None;
1743    let mut traversal_attempts: usize = 0;
1744
1745    for entry in files {
1746        let rel = std::path::Path::new(&entry.path);
1747        if rel
1748            .components()
1749            .any(|c| matches!(c, std::path::Component::ParentDir))
1750        {
1751            traversal_attempts += 1;
1752            if traversal_attempts >= 5 {
1753                let _ = tokio::fs::remove_dir_all(staging).await;
1754                tracing::warn!(
1755                    event = "upload_path_traversal",
1756                    upload_id = %upload_id,
1757                    "Upload rejected: repeated path traversal attempts detected"
1758                );
1759                return Err((
1760                    StatusCode::BAD_REQUEST,
1761                    Json(serde_json::json!({"error": "Upload rejected"})),
1762                )
1763                    .into_response());
1764            }
1765            continue;
1766        }
1767
1768        if let Err(resp) =
1769            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
1770        {
1771            let _ = tokio::fs::remove_dir_all(staging).await;
1772            return Err(resp);
1773        }
1774    }
1775
1776    Ok((files.len(), project_root))
1777}
1778
1779/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
1780/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
1781fn parse_tarball_size_caps() -> (u64, u64) {
1782    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
1783        .ok()
1784        .and_then(|v| v.parse().ok())
1785        .unwrap_or(2048_u64)
1786        * 1024
1787        * 1024;
1788    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
1789        .ok()
1790        .and_then(|v| v.parse().ok())
1791        .unwrap_or(10_240_u64)
1792        * 1024
1793        * 1024;
1794    (compressed, decompressed)
1795}
1796
1797/// Stream `body` into `dest_path`, enforcing `max_bytes`.
1798/// Returns the number of compressed bytes written, or an error `Response`.
1799/// Cleans up `dest_path` on error.
1800#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
1801async fn stream_body_to_file(
1802    body: axum::body::Body,
1803    dest_path: &Path,
1804    max_bytes: u64,
1805) -> Result<u64, Response> {
1806    use http_body_util::BodyExt as _;
1807    use tokio::io::AsyncWriteExt as _;
1808
1809    let mut file = match tokio::fs::File::create(dest_path).await {
1810        Ok(f) => f,
1811        Err(e) => {
1812            tracing::error!(
1813                event = "upload_io_error",
1814                "failed to create tarball temp file: {e}"
1815            );
1816            return Err((
1817                StatusCode::INTERNAL_SERVER_ERROR,
1818                Json(serde_json::json!({"error": "Upload initialization failed"})),
1819            )
1820                .into_response());
1821        }
1822    };
1823
1824    let mut body = body;
1825    let mut written: u64 = 0;
1826    loop {
1827        match body.frame().await {
1828            None => break,
1829            Some(Err(e)) => {
1830                let _ = tokio::fs::remove_file(dest_path).await;
1831                return Err((
1832                    StatusCode::BAD_REQUEST,
1833                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
1834                )
1835                    .into_response());
1836            }
1837            Some(Ok(frame)) => {
1838                if let Ok(data) = frame.into_data() {
1839                    written += data.len() as u64;
1840                    if written > max_bytes {
1841                        let _ = tokio::fs::remove_file(dest_path).await;
1842                        return Err((
1843                            StatusCode::PAYLOAD_TOO_LARGE,
1844                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
1845                        )
1846                            .into_response());
1847                    }
1848                    if let Err(e) = file.write_all(&data).await {
1849                        let _ = tokio::fs::remove_file(dest_path).await;
1850                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
1851                        return Err((
1852                            StatusCode::INTERNAL_SERVER_ERROR,
1853                            Json(serde_json::json!({"error": "Upload write failed"})),
1854                        )
1855                            .into_response());
1856                    }
1857                }
1858            }
1859        }
1860    }
1861    drop(file);
1862    Ok(written)
1863}
1864
1865/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
1866/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
1867/// on failure (staging dir is cleaned up before returning).
1868#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
1869async fn extract_tarball_to_staging(
1870    tarball_path: &Path,
1871    staging: &Path,
1872    max_decompressed_bytes: u64,
1873) -> Result<(), Response> {
1874    let staging_clone = staging.to_path_buf();
1875    let tarball_clone = tarball_path.to_path_buf();
1876    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
1877        let file = std::fs::File::open(&tarball_clone)?;
1878        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
1879        let limited = SizeLimitReader {
1880            inner: gz,
1881            remaining: max_decompressed_bytes,
1882        };
1883        let mut archive = tar::Archive::new(limited);
1884        archive.set_overwrite(true);
1885        archive.set_preserve_permissions(false);
1886        std::fs::create_dir_all(&staging_clone)?;
1887        archive.unpack(&staging_clone)?;
1888        Ok(())
1889    })
1890    .await;
1891    let _ = tokio::fs::remove_file(tarball_path).await;
1892
1893    match extract_result {
1894        Ok(Ok(())) => Ok(()),
1895        Ok(Err(e)) => {
1896            let _ = tokio::fs::remove_dir_all(staging).await;
1897            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
1898            tracing::warn!(
1899                event = "upload_extract_error",
1900                "tarball extraction failed: {e:#}"
1901            );
1902            let (status, msg) = if is_size_limit {
1903                (
1904                    StatusCode::PAYLOAD_TOO_LARGE,
1905                    "Archive exceeds the decompressed size limit",
1906                )
1907            } else {
1908                (StatusCode::BAD_REQUEST, "Failed to extract archive")
1909            };
1910            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
1911        }
1912        Err(e) => {
1913            let _ = tokio::fs::remove_dir_all(staging).await;
1914            tracing::error!(
1915                event = "upload_extract_panic",
1916                "tarball extraction task panicked: {e}"
1917            );
1918            Err((
1919                StatusCode::INTERNAL_SERVER_ERROR,
1920                Json(serde_json::json!({"error": "Archive extraction failed"})),
1921            )
1922                .into_response())
1923        }
1924    }
1925}
1926
1927/// If `staging` contains exactly one top-level directory, return its path
1928/// (the common case when the archive was created with `webkitRelativePath`).
1929/// Otherwise return `None`.
1930async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
1931    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
1932    let first = entries.next_entry().await.ok()??;
1933    if !first.path().is_dir() {
1934        return None;
1935    }
1936    if entries.next_entry().await.unwrap_or(None).is_some() {
1937        return None;
1938    }
1939    Some(first.path())
1940}
1941
1942/// Request body for `POST /api/upload-directory`.
1943///
1944/// Each entry carries a relative path (identical to the browser's
1945/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
1946/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
1947/// avoids pulling in a `multipart` library that is not in the vendor archive.
1948#[derive(Deserialize)]
1949struct UploadDirRequest {
1950    files: Vec<UploadedFile>,
1951    /// If provided, append this batch to an existing upload session instead of
1952    /// creating a new staging directory. Must be a plain UUID (no path separators).
1953    upload_id: Option<String>,
1954}
1955
1956#[derive(Deserialize)]
1957struct UploadedFile {
1958    /// `webkitRelativePath` value from the browser File object.
1959    path: String,
1960    /// Raw file bytes encoded as standard base64.
1961    content: String,
1962}
1963
1964/// POST /api/upload-directory
1965///
1966/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
1967/// Saves all files to a temp staging directory preserving their relative paths,
1968/// then returns the server-side root directory path so the caller can populate
1969/// the scan-path field and run a normal analysis.
1970///
1971/// Only available in server mode; returns 404 in local mode (use the native
1972/// rfd dialog instead).
1973async fn upload_directory_handler(
1974    State(state): State<AppState>,
1975    Json(body): Json<UploadDirRequest>,
1976) -> Response {
1977    if !state.server_mode {
1978        return StatusCode::NOT_FOUND.into_response();
1979    }
1980    if let Err(resp) = validate_upload_dir_request(&body) {
1981        return resp;
1982    }
1983    // Reuse an existing staging dir when the client sends a continuation batch,
1984    // otherwise create a fresh one. Validate the id to prevent path traversal.
1985    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
1986    match write_upload_files(&body.files, &staging, &upload_id).await {
1987        Ok((file_count, project_root)) => {
1988            let scan_root = project_root.unwrap_or_else(|| staging.clone());
1989            Json(serde_json::json!({
1990                "tmp_path": scan_root.to_string_lossy(),
1991                "file_count": file_count,
1992                "upload_id": upload_id.clone()
1993            }))
1994            .into_response()
1995        }
1996        Err(resp) => resp,
1997    }
1998}
1999
2000/// Request body for `POST /api/upload-file`.
2001#[derive(Deserialize)]
2002struct UploadFileRequest {
2003    /// Original filename (used only to preserve the extension).
2004    filename: String,
2005    /// File bytes encoded as standard base64.
2006    content: String,
2007}
2008
2009/// POST /api/upload-file
2010///
2011/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2012/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2013/// Only available in server mode.
2014async fn upload_file_handler(
2015    State(state): State<AppState>,
2016    Json(body): Json<UploadFileRequest>,
2017) -> Response {
2018    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2019
2020    if !state.server_mode {
2021        return StatusCode::NOT_FOUND.into_response();
2022    }
2023
2024    let Ok(data) = base64::Engine::decode(
2025        &base64::engine::general_purpose::STANDARD,
2026        body.content.as_bytes(),
2027    ) else {
2028        return (
2029            StatusCode::BAD_REQUEST,
2030            Json(serde_json::json!({"error": "Invalid base64 content"})),
2031        )
2032            .into_response();
2033    };
2034
2035    if data.len() > MAX_FILE_BYTES {
2036        return (
2037            StatusCode::PAYLOAD_TOO_LARGE,
2038            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2039        )
2040            .into_response();
2041    }
2042
2043    // Sanitise: strip any directory component from the filename.
2044    let filename = std::path::Path::new(&body.filename)
2045        .file_name()
2046        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2047
2048    let upload_id = uuid::Uuid::new_v4();
2049    let staging = std::env::temp_dir()
2050        .join("oxide-sloc-uploads")
2051        .join(upload_id.to_string());
2052
2053    if tokio::fs::create_dir_all(&staging).await.is_err() {
2054        return (
2055            StatusCode::INTERNAL_SERVER_ERROR,
2056            Json(serde_json::json!({"error": "Failed to create staging directory"})),
2057        )
2058            .into_response();
2059    }
2060
2061    let dest = staging.join(&filename);
2062    if tokio::fs::write(&dest, &data).await.is_err() {
2063        let _ = tokio::fs::remove_dir_all(&staging).await;
2064        return (
2065            StatusCode::INTERNAL_SERVER_ERROR,
2066            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2067        )
2068            .into_response();
2069    }
2070
2071    Json(serde_json::json!({
2072        "tmp_path": dest.to_string_lossy(),
2073        "upload_id": upload_id.to_string()
2074    }))
2075    .into_response()
2076}
2077
2078/// POST /api/upload-tarball
2079///
2080/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
2081/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
2082/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
2083/// project root. The two size fields power the "Original / Compressed project size" display in the
2084/// web UI.
2085///
2086/// `DefaultBodyLimit::disable()` is applied per-route so there is no hard size cap at the HTTP
2087/// layer; the only limit is the disk space on the server. The browser-side JS creates the archive
2088/// one file at a time using the native `CompressionStream('gzip')` API so browser RAM usage stays
2089/// bounded regardless of project size.
2090/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
2091/// decompressed. Wraps any `std::io::Read` source.
2092struct SizeLimitReader<R> {
2093    inner: R,
2094    remaining: u64,
2095}
2096impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2097    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2098        if self.remaining == 0 {
2099            return Err(std::io::Error::other("decompressed size limit exceeded"));
2100        }
2101        let n = self.inner.read(buf)?;
2102        self.remaining = self.remaining.saturating_sub(n as u64);
2103        Ok(n)
2104    }
2105}
2106
2107async fn upload_tarball_handler(
2108    State(state): State<AppState>,
2109    request: axum::extract::Request,
2110) -> Response {
2111    if !state.server_mode {
2112        return StatusCode::NOT_FOUND.into_response();
2113    }
2114
2115    let upload_id = uuid::Uuid::new_v4().to_string();
2116    let upload_base = upload_base_dir();
2117    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2118    let staging = upload_staging_path(&upload_id);
2119    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2120
2121    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2122        tracing::error!(
2123            event = "upload_io_error",
2124            "failed to create upload base dir: {e}"
2125        );
2126        return (
2127            StatusCode::INTERNAL_SERVER_ERROR,
2128            Json(serde_json::json!({"error": "Upload initialization failed"})),
2129        )
2130            .into_response();
2131    }
2132
2133    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
2134    let compressed_bytes =
2135        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2136            Ok(n) => n,
2137            Err(resp) => return resp,
2138        };
2139
2140    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
2141    if let Err(resp) =
2142        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2143    {
2144        return resp;
2145    }
2146
2147    // ── 3. Find the project root inside the staging dir ───────────────────────
2148    // If the tar contained a single top-level directory (the common case when the
2149    // browser uses `webkitRelativePath`), return that as the scan root so the path
2150    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
2151    let scan_root = find_single_top_dir(&staging)
2152        .await
2153        .unwrap_or_else(|| staging.clone());
2154
2155    // Compute original (uncompressed) size of the extracted tree.
2156    let original_bytes = tokio::task::spawn_blocking({
2157        let p = scan_root.clone();
2158        move || dir_size_bytes(&p)
2159    })
2160    .await
2161    .unwrap_or(0);
2162
2163    Json(serde_json::json!({
2164        "tmp_path": scan_root.to_string_lossy(),
2165        "upload_id": upload_id,
2166        "compressed_bytes": compressed_bytes,
2167        "original_bytes": original_bytes,
2168    }))
2169    .into_response()
2170}
2171
2172#[derive(Deserialize)]
2173struct LocateReportForm {
2174    file_path: String,
2175}
2176
2177/// Render a view-reports error page and return it as a `Response`.
2178fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2179    let html = ErrorTemplate {
2180        message: message.into(),
2181        last_report_url: Some("/view-reports".to_string()),
2182        last_report_label: Some("View Reports".to_string()),
2183        csp_nonce: csp_nonce.to_owned(),
2184        version: env!("CARGO_PKG_VERSION"),
2185    }
2186    .render()
2187    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2188    Html(html).into_response()
2189}
2190
2191/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
2192fn registry_entry_from_run(
2193    run: &AnalysisRun,
2194    json_path: PathBuf,
2195    html_path: PathBuf,
2196) -> RegistryEntry {
2197    let project_label = run.input_roots.first().map_or_else(
2198        || "Unknown Project".to_string(),
2199        |r| sanitize_project_label(r),
2200    );
2201    RegistryEntry {
2202        run_id: run.tool.run_id.clone(),
2203        timestamp_utc: run.tool.timestamp_utc,
2204        project_label,
2205        input_roots: run.input_roots.clone(),
2206        json_path: Some(json_path),
2207        html_path: Some(html_path),
2208        pdf_path: None,
2209        summary: ScanSummarySnapshot {
2210            files_analyzed: run.summary_totals.files_analyzed,
2211            files_skipped: run.summary_totals.files_skipped,
2212            total_physical_lines: run.summary_totals.total_physical_lines,
2213            code_lines: run.summary_totals.code_lines,
2214            comment_lines: run.summary_totals.comment_lines,
2215            blank_lines: run.summary_totals.blank_lines,
2216            functions: run.summary_totals.functions,
2217            classes: run.summary_totals.classes,
2218            variables: run.summary_totals.variables,
2219            imports: run.summary_totals.imports,
2220            test_count: run.summary_totals.test_count,
2221            coverage_lines_found: run.summary_totals.coverage_lines_found,
2222            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2223            coverage_functions_found: run.summary_totals.coverage_functions_found,
2224            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2225            coverage_branches_found: run.summary_totals.coverage_branches_found,
2226            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2227        },
2228        csv_path: None,
2229        xlsx_path: None,
2230        git_branch: None,
2231        git_commit: None,
2232        git_author: None,
2233        git_tags: None,
2234        git_nearest_tag: None,
2235        git_commit_date: None,
2236    }
2237}
2238
2239/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
2240/// immediately without requiring a server restart.
2241pub(crate) async fn register_artifacts_in_registry(
2242    state: &AppState,
2243    label: &str,
2244    run: &AnalysisRun,
2245    artifacts: &RunArtifacts,
2246) {
2247    let Some(json_path) = artifacts.json_path.clone() else {
2248        return;
2249    };
2250    let Some(html_path) = artifacts.html_path.clone() else {
2251        return;
2252    };
2253    let mut entry = registry_entry_from_run(run, json_path, html_path);
2254    entry.project_label = label.to_owned();
2255    let mut reg = state.registry.lock().await;
2256    reg.add_entry(entry);
2257    let _ = reg.save(&state.registry_path);
2258}
2259
2260/// Validate the locate-report form: check extension, resolve the canonical path, enforce
2261/// server-mode root restriction, and extract the parent directory.
2262///
2263/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
2264#[allow(clippy::result_large_err)]
2265fn validate_locate_request(
2266    state: &AppState,
2267    file_path: &str,
2268    csp_nonce: &str,
2269) -> Result<(PathBuf, PathBuf), Response> {
2270    let file_ext = Path::new(file_path)
2271        .extension()
2272        .and_then(|e| e.to_str())
2273        .unwrap_or("")
2274        .to_ascii_lowercase();
2275    if file_ext != "html" {
2276        return Err(locate_report_error(
2277            "Only .html report files can be located via this form.",
2278            csp_nonce,
2279        ));
2280    }
2281    let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
2282        Ok(p) => strip_unc_prefix(p),
2283        Err(_) => {
2284            return Err(locate_report_error(
2285                "Report file not found or path is invalid.",
2286                csp_nonce,
2287            ));
2288        }
2289    };
2290    if state.server_mode {
2291        let output_root = resolve_output_root(None);
2292        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2293        if !html_path.starts_with(&canonical_root) {
2294            return Err(locate_report_error(
2295                "Report file must be within the configured output directory.",
2296                csp_nonce,
2297            ));
2298        }
2299    }
2300    let parent = match html_path.parent() {
2301        Some(p) => p.to_path_buf(),
2302        None => {
2303            return Err(locate_report_error(
2304                "Report file has no parent directory.",
2305                csp_nonce,
2306            ));
2307        }
2308    };
2309    Ok((html_path, parent))
2310}
2311
2312/// Return a non-sensitive path hint for error messages (empty in server mode).
2313fn locate_path_hint(server_mode: bool, path: &Path) -> String {
2314    if server_mode {
2315        String::new()
2316    } else {
2317        format!("\n\nFile: {}", path.display())
2318    }
2319}
2320
2321async fn locate_report_handler(
2322    State(state): State<AppState>,
2323    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2324    Form(form): Form<LocateReportForm>,
2325) -> impl IntoResponse {
2326    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2327        Ok(v) => v,
2328        Err(resp) => return resp,
2329    };
2330
2331    let json_candidate = parent.join("result.json");
2332    let mut reg = state.registry.lock().await;
2333    // Find an existing entry whose output directory matches the selected file's parent.
2334    let entry_idx = reg.entries.iter().position(|e| {
2335        let json_match = e
2336            .json_path
2337            .as_ref()
2338            .and_then(|p| p.parent())
2339            .is_some_and(|p| p == parent);
2340        let html_match = e
2341            .html_path
2342            .as_ref()
2343            .and_then(|p| p.parent())
2344            .is_some_and(|p| p == parent);
2345        json_match || html_match
2346    });
2347    if let Some(idx) = entry_idx {
2348        reg.entries[idx].html_path = Some(html_path);
2349        let _ = reg.save(&state.registry_path);
2350        return axum::response::Redirect::to("/view-reports?linked=1").into_response();
2351    }
2352    // No match — attempt to build an entry from an adjacent result.json.
2353    if json_candidate.exists() {
2354        match read_json(&json_candidate) {
2355            Ok(run) => {
2356                let entry = registry_entry_from_run(&run, json_candidate, html_path);
2357                reg.add_entry(entry);
2358                let _ = reg.save(&state.registry_path);
2359                return axum::response::Redirect::to("/view-reports?linked=1").into_response();
2360            }
2361            Err(e) => {
2362                let file_hint = locate_path_hint(state.server_mode, &json_candidate);
2363                let err_detail = if state.server_mode {
2364                    String::new()
2365                } else {
2366                    format!("\n\nError: {e}")
2367                };
2368                return locate_report_error(
2369                    format!(
2370                        "Could not link this report.\n\nA 'result.json' was found but could not \
2371                         be parsed — it may have been saved by an older version of OxideSLOC. \
2372                         Re-running the analysis will create a fresh, compatible \
2373                         record.{file_hint}{err_detail}"
2374                    ),
2375                    &csp_nonce,
2376                );
2377            }
2378        }
2379    }
2380    drop(reg);
2381    let file_hint = locate_path_hint(state.server_mode, &html_path);
2382    locate_report_error(
2383        format!(
2384            "Could not link this report.\n\nNo matching scan record was found, and no \
2385             'result.json' was found in the same folder.{file_hint}"
2386        ),
2387        &csp_nonce,
2388    )
2389}
2390
2391/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
2392fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2393    fs::read_dir(dir)
2394        .ok()?
2395        .flatten()
2396        .map(|e| e.path())
2397        .find(|p| {
2398            p.is_file()
2399                && p.file_stem()
2400                    .and_then(|n| n.to_str())
2401                    .is_some_and(|n| n.starts_with("result"))
2402                && p.extension()
2403                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2404        })
2405}
2406
2407#[derive(Deserialize)]
2408struct LocateReportsDirForm {
2409    folder_path: String,
2410}
2411
2412#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
2413async fn locate_reports_dir_handler(
2414    State(state): State<AppState>,
2415    Form(form): Form<LocateReportsDirForm>,
2416) -> impl IntoResponse {
2417    if state.server_mode {
2418        return StatusCode::NOT_FOUND.into_response();
2419    }
2420    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
2421        Ok(p) => strip_unc_prefix(p),
2422        Err(_) => {
2423            return axum::response::Redirect::to(
2424                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
2425            )
2426            .into_response();
2427        }
2428    };
2429    if !folder.is_dir() {
2430        return axum::response::Redirect::to(
2431            "/view-reports?error=Selected+path+is+not+a+directory.",
2432        )
2433        .into_response();
2434    }
2435
2436    let candidates = collect_result_json_candidates(&folder);
2437
2438    if candidates.is_empty() {
2439        return axum::response::Redirect::to(
2440            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
2441        )
2442        .into_response();
2443    }
2444
2445    let mut linked_count: usize = 0;
2446    let mut reg = state.registry.lock().await;
2447    for json_path in candidates {
2448        let Some(parent) = json_path.parent().map(PathBuf::from) else {
2449            continue;
2450        };
2451        if is_dir_already_registered(&reg, &parent) {
2452            continue;
2453        }
2454        let Some(entry) = build_registry_entry_from_json(json_path) else {
2455            continue;
2456        };
2457        reg.add_entry(entry);
2458        linked_count += 1;
2459    }
2460    let _ = reg.save(&state.registry_path);
2461    drop(reg);
2462
2463    if linked_count == 0 {
2464        return axum::response::Redirect::to(
2465            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2466        )
2467        .into_response();
2468    }
2469    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2470}
2471
2472#[derive(Deserialize)]
2473struct RelocateScanForm {
2474    run_id: String,
2475    folder_path: String,
2476    redirect_url: String,
2477}
2478
2479async fn relocate_scan_handler(
2480    State(state): State<AppState>,
2481    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2482    Form(form): Form<RelocateScanForm>,
2483) -> impl IntoResponse {
2484    if state.server_mode {
2485        return StatusCode::NOT_FOUND.into_response();
2486    }
2487
2488    let run_id = form.run_id.trim().to_string();
2489    let redirect_url = form.redirect_url.trim().to_string();
2490
2491    let run_exists = {
2492        let reg = state.registry.lock().await;
2493        reg.find_by_run_id(&run_id).is_some()
2494    };
2495    if !run_exists {
2496        let html = ErrorTemplate {
2497            message: format!("Run ID '{run_id}' not found in registry."),
2498            last_report_url: Some("/compare-scans".to_string()),
2499            last_report_label: Some("Compare Scans".to_string()),
2500            csp_nonce: csp_nonce.clone(),
2501            version: env!("CARGO_PKG_VERSION"),
2502        }
2503        .render()
2504        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2505        return Html(html).into_response();
2506    }
2507
2508    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2509        Ok(p) => strip_unc_prefix(p),
2510        Err(_) => {
2511            return missing_scan_relocate_response(
2512                "Folder not found or path is invalid.",
2513                &run_id,
2514                form.folder_path.trim(),
2515                &redirect_url,
2516                false,
2517                &csp_nonce,
2518            );
2519        }
2520    };
2521    if !folder.is_dir() {
2522        return missing_scan_relocate_response(
2523            "Selected path is not a directory.",
2524            &run_id,
2525            &folder.display().to_string(),
2526            &redirect_url,
2527            false,
2528            &csp_nonce,
2529        );
2530    }
2531
2532    let json_candidates = find_result_files_by_ext(&folder, "json");
2533    if json_candidates.is_empty() {
2534        return missing_scan_relocate_response(
2535            &format!(
2536                "No result JSON files found in the selected folder.\nSearched: {}",
2537                folder.display()
2538            ),
2539            &run_id,
2540            &folder.display().to_string(),
2541            &redirect_url,
2542            false,
2543            &csp_nonce,
2544        );
2545    }
2546
2547    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
2548        return missing_scan_relocate_response(
2549            &format!(
2550                "No matching scan found in the selected folder.\n\
2551                 The JSON files present do not contain run ID: {run_id}\n\
2552                 Searched: {}",
2553                folder.display()
2554            ),
2555            &run_id,
2556            &folder.display().to_string(),
2557            &redirect_url,
2558            false,
2559            &csp_nonce,
2560        );
2561    };
2562
2563    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
2564    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
2565    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
2566
2567    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
2568        redirect_url
2569    } else {
2570        "/compare-scans".to_string()
2571    };
2572    axum::response::Redirect::to(&safe_redirect).into_response()
2573}
2574
2575fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
2576    fs::read_dir(folder)
2577        .ok()
2578        .into_iter()
2579        .flatten()
2580        .flatten()
2581        .map(|e| e.path())
2582        .filter(|p| {
2583            p.is_file()
2584                && p.file_stem()
2585                    .and_then(|n| n.to_str())
2586                    .is_some_and(|n| n.starts_with("result"))
2587                && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
2588        })
2589        .collect()
2590}
2591
2592fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
2593    candidates
2594        .iter()
2595        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
2596        .cloned()
2597}
2598
2599async fn update_run_file_paths(
2600    state: &AppState,
2601    run_id: &str,
2602    json_path: PathBuf,
2603    html_path: Option<PathBuf>,
2604    pdf_path: Option<PathBuf>,
2605) {
2606    let mut reg = state.registry.lock().await;
2607    if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2608        entry.json_path = Some(json_path);
2609        if let Some(hp) = html_path {
2610            entry.html_path = Some(hp);
2611        }
2612        if let Some(pp) = pdf_path {
2613            entry.pdf_path = Some(pp);
2614        }
2615    }
2616    let _ = reg.save(&state.registry_path);
2617}
2618
2619fn missing_scan_relocate_response(
2620    message: &str,
2621    run_id: &str,
2622    folder_hint: &str,
2623    redirect_url: &str,
2624    server_mode: bool,
2625    csp_nonce: &str,
2626) -> axum::response::Response {
2627    let html = RelocateScanTemplate {
2628        message: message.to_string(),
2629        run_id: run_id.to_string(),
2630        folder_hint: folder_hint.to_string(),
2631        redirect_url: redirect_url.to_string(),
2632        server_mode,
2633        csp_nonce: csp_nonce.to_owned(),
2634        version: env!("CARGO_PKG_VERSION"),
2635    }
2636    .render()
2637    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2638    (StatusCode::NOT_FOUND, Html(html)).into_response()
2639}
2640
2641// ── Watched-directory helpers ─────────────────────────────────────────────────
2642
2643/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
2644fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
2645    let mut candidates = Vec::new();
2646    if let Some(j) = find_result_json_in_dir(folder) {
2647        candidates.push(j);
2648    }
2649    if let Ok(dir_entries) = fs::read_dir(folder) {
2650        for entry in dir_entries.flatten() {
2651            let sub = entry.path();
2652            if sub.is_dir() {
2653                if let Some(j) = find_result_json_in_dir(&sub) {
2654                    candidates.push(j);
2655                }
2656            }
2657        }
2658    }
2659    candidates
2660}
2661
2662fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
2663    reg.entries.iter().any(|e| {
2664        let dir_match = e
2665            .json_path
2666            .as_ref()
2667            .and_then(|p| p.parent())
2668            .is_some_and(|p| p == parent)
2669            || e.html_path
2670                .as_ref()
2671                .and_then(|p| p.parent())
2672                .is_some_and(|p| p == parent);
2673        dir_match
2674            && (e.json_path.as_ref().is_some_and(|p| p.exists())
2675                || e.html_path.as_ref().is_some_and(|p| p.exists()))
2676    })
2677}
2678
2679fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
2680    let parent = json_path.parent()?.to_path_buf();
2681    let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
2682        rd.flatten()
2683            .map(|e| e.path())
2684            .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
2685    });
2686    let run = read_json(&json_path).ok()?;
2687    let project_label = run.input_roots.first().map_or_else(
2688        || "Unknown Project".to_string(),
2689        |r| sanitize_project_label(r),
2690    );
2691    Some(RegistryEntry {
2692        run_id: run.tool.run_id.clone(),
2693        timestamp_utc: run.tool.timestamp_utc,
2694        project_label,
2695        input_roots: run.input_roots.clone(),
2696        json_path: Some(json_path),
2697        html_path,
2698        pdf_path: None,
2699        csv_path: None,
2700        xlsx_path: None,
2701        summary: ScanSummarySnapshot {
2702            files_analyzed: run.summary_totals.files_analyzed,
2703            files_skipped: run.summary_totals.files_skipped,
2704            total_physical_lines: run.summary_totals.total_physical_lines,
2705            code_lines: run.summary_totals.code_lines,
2706            comment_lines: run.summary_totals.comment_lines,
2707            blank_lines: run.summary_totals.blank_lines,
2708            functions: run.summary_totals.functions,
2709            classes: run.summary_totals.classes,
2710            variables: run.summary_totals.variables,
2711            imports: run.summary_totals.imports,
2712            test_count: run.summary_totals.test_count,
2713            coverage_lines_found: run.summary_totals.coverage_lines_found,
2714            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2715            coverage_functions_found: run.summary_totals.coverage_functions_found,
2716            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2717            coverage_branches_found: run.summary_totals.coverage_branches_found,
2718            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2719        },
2720        git_branch: run.git_branch.clone(),
2721        git_commit: run.git_commit_short.clone(),
2722        git_author: run.git_commit_author.clone(),
2723        git_tags: run.git_tags.clone(),
2724        git_nearest_tag: run.git_nearest_tag.clone(),
2725        git_commit_date: run.git_commit_date,
2726    })
2727}
2728
2729/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
2730/// Returns the number of newly linked entries.
2731fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
2732    let mut linked = 0usize;
2733    for json_path in collect_result_json_candidates(folder) {
2734        let Some(parent) = json_path.parent().map(PathBuf::from) else {
2735            continue;
2736        };
2737        if is_dir_already_registered(reg, &parent) {
2738            continue;
2739        }
2740        let Some(entry) = build_registry_entry_from_json(json_path) else {
2741            continue;
2742        };
2743        reg.add_entry(entry);
2744        linked += 1;
2745    }
2746    linked
2747}
2748
2749/// Scan all watched directories (plus the default output root) into `reg`.
2750async fn auto_scan_watched_dirs(state: &AppState) {
2751    let dirs: Vec<PathBuf> = {
2752        let wd = state.watched_dirs.lock().await;
2753        wd.dirs.clone()
2754    };
2755    if dirs.is_empty() {
2756        return;
2757    }
2758    let mut reg = state.registry.lock().await;
2759    let mut total = 0usize;
2760    for dir in &dirs {
2761        if dir.is_dir() {
2762            total += scan_folder_into_registry(dir, &mut reg);
2763        }
2764    }
2765    if total > 0 {
2766        let _ = reg.save(&state.registry_path);
2767    }
2768}
2769
2770// ── Watched-dir route forms ───────────────────────────────────────────────────
2771
2772#[derive(Deserialize)]
2773struct WatchedDirForm {
2774    folder_path: String,
2775    #[serde(default = "default_redirect")]
2776    redirect_to: String,
2777}
2778
2779fn default_redirect() -> String {
2780    "/view-reports".to_string()
2781}
2782
2783#[derive(Deserialize)]
2784struct WatchedDirRefreshForm {
2785    #[serde(default = "default_redirect")]
2786    redirect_to: String,
2787}
2788
2789// ── Watched-dir helpers ───────────────────────────────────────────────────────
2790
2791/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
2792fn safe_redirect(dest: &str) -> &str {
2793    if dest.starts_with('/') {
2794        dest
2795    } else {
2796        "/"
2797    }
2798}
2799
2800// ── Watched-dir handlers ──────────────────────────────────────────────────────
2801
2802async fn add_watched_dir_handler(
2803    State(state): State<AppState>,
2804    Form(form): Form<WatchedDirForm>,
2805) -> impl IntoResponse {
2806    if state.server_mode {
2807        return StatusCode::NOT_FOUND.into_response();
2808    }
2809    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2810        strip_unc_prefix(p)
2811    } else {
2812        let dest = format!(
2813            "{}?error=Folder+not+found+or+path+is+invalid.",
2814            safe_redirect(&form.redirect_to)
2815        );
2816        return axum::response::Redirect::to(&dest).into_response();
2817    };
2818    if !folder.is_dir() {
2819        let dest = format!(
2820            "{}?error=Selected+path+is+not+a+directory.",
2821            safe_redirect(&form.redirect_to)
2822        );
2823        return axum::response::Redirect::to(&dest).into_response();
2824    }
2825
2826    // Persist the watched directory.
2827    {
2828        let mut wd = state.watched_dirs.lock().await;
2829        wd.add(folder.clone());
2830        let _ = wd.save(&state.watched_dirs_path);
2831    }
2832
2833    // Immediately scan the folder and add any new reports.
2834    let linked = {
2835        let mut reg = state.registry.lock().await;
2836        let n = scan_folder_into_registry(&folder, &mut reg);
2837        if n > 0 {
2838            let _ = reg.save(&state.registry_path);
2839        }
2840        n
2841    };
2842
2843    let dest = if linked > 0 {
2844        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2845    } else {
2846        format!(
2847            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2848            safe_redirect(&form.redirect_to)
2849        )
2850    };
2851    axum::response::Redirect::to(&dest).into_response()
2852}
2853
2854async fn remove_watched_dir_handler(
2855    State(state): State<AppState>,
2856    Form(form): Form<WatchedDirForm>,
2857) -> impl IntoResponse {
2858    if state.server_mode {
2859        return StatusCode::NOT_FOUND.into_response();
2860    }
2861    let folder = PathBuf::from(&form.folder_path);
2862    {
2863        let mut wd = state.watched_dirs.lock().await;
2864        wd.remove(&folder);
2865        let _ = wd.save(&state.watched_dirs_path);
2866    }
2867    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2868}
2869
2870async fn refresh_watched_dirs_handler(
2871    State(state): State<AppState>,
2872    Form(form): Form<WatchedDirRefreshForm>,
2873) -> impl IntoResponse {
2874    if state.server_mode {
2875        return StatusCode::NOT_FOUND.into_response();
2876    }
2877    let dirs: Vec<PathBuf> = {
2878        let wd = state.watched_dirs.lock().await;
2879        wd.dirs.clone()
2880    };
2881    let mut total = 0usize;
2882    {
2883        let mut reg = state.registry.lock().await;
2884        for dir in &dirs {
2885            if dir.is_dir() {
2886                total += scan_folder_into_registry(dir, &mut reg);
2887            }
2888        }
2889        if total > 0 {
2890            let _ = reg.save(&state.registry_path);
2891        }
2892    }
2893    let dest = if total > 0 {
2894        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2895    } else {
2896        safe_redirect(&form.redirect_to).to_owned()
2897    };
2898    axum::response::Redirect::to(&dest).into_response()
2899}
2900
2901#[derive(Debug, Deserialize)]
2902struct OpenPathQuery {
2903    path: Option<String>,
2904}
2905
2906async fn open_path_handler(
2907    State(state): State<AppState>,
2908    Query(query): Query<OpenPathQuery>,
2909) -> impl IntoResponse {
2910    if state.server_mode {
2911        return Json(serde_json::json!({
2912            "server_mode_disabled": true,
2913            "message": "Opening a path in the file manager is only available in local desktop mode."
2914        }))
2915        .into_response();
2916    }
2917    let raw = match query.path.as_deref() {
2918        Some(p) if !p.is_empty() => p,
2919        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2920    };
2921
2922    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
2923    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
2924    // so the file explorer still opens somewhere useful.
2925    let target = match fs::canonicalize(raw) {
2926        Ok(canonical) if canonical.is_file() => match canonical.parent() {
2927            Some(p) => p.to_path_buf(),
2928            None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2929        },
2930        Ok(canonical) if canonical.is_dir() => canonical,
2931        Ok(_) => {
2932            return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2933        }
2934        Err(_) => {
2935            // Path doesn't exist — find nearest existing ancestor directory.
2936            let mut ancestor = std::path::Path::new(raw);
2937            loop {
2938                match ancestor.parent() {
2939                    Some(p) => {
2940                        ancestor = p;
2941                        if ancestor.is_dir() {
2942                            break;
2943                        }
2944                    }
2945                    None => {
2946                        return (StatusCode::BAD_REQUEST, "no existing ancestor found")
2947                            .into_response();
2948                    }
2949                }
2950            }
2951            ancestor.to_path_buf()
2952        }
2953    };
2954
2955    #[cfg(target_os = "windows")]
2956    {
2957        // Open the folder in Explorer, then use SetForegroundWindow + ShowWindow(SW_MAXIMIZE=3)
2958        // to ensure the window surfaces on top of all other windows.  The path is passed via
2959        // an environment variable to avoid any command-injection or escaping issues.
2960        let ps_cmd = "Add-Type -TypeDefinition \
2961            'using System;using System.Runtime.InteropServices;\
2962            public class WF{\
2963              [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
2964              [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
2965            }'; \
2966            $p=$env:SLOC_OPEN_PATH; \
2967            $sh=New-Object -ComObject Shell.Application; \
2968            $sh.Open($p); \
2969            Start-Sleep -Milliseconds 600; \
2970            foreach($w in $sh.Windows()){ \
2971              try{ \
2972                if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
2973                   [System.IO.Path]::GetFullPath($p)){ \
2974                  [WF]::ShowWindow($w.HWND,3); \
2975                  [WF]::SetForegroundWindow($w.HWND); \
2976                  break \
2977                } \
2978              }catch{} \
2979            }";
2980        let _ = std::process::Command::new("powershell")
2981            .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
2982            .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
2983            .stdout(Stdio::null())
2984            .stderr(Stdio::null())
2985            .spawn();
2986    }
2987    #[cfg(target_os = "macos")]
2988    let _ = std::process::Command::new("open")
2989        .arg(&target)
2990        .stdout(Stdio::null())
2991        .stderr(Stdio::null())
2992        .spawn();
2993    #[cfg(target_os = "linux")]
2994    let _ = std::process::Command::new("xdg-open")
2995        .arg(&target)
2996        .stdout(Stdio::null())
2997        .stderr(Stdio::null())
2998        .spawn();
2999
3000    (StatusCode::OK, "ok").into_response()
3001}
3002
3003async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3004    let (content_type, bytes): (&'static str, &'static [u8]) =
3005        match (folder.as_str(), file.as_str()) {
3006            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3007            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3008            ("icons", "c.png") => ("image/png", IMG_ICON_C),
3009            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3010            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3011            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3012            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3013            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3014            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3015            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3016            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3017            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3018            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3019            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3020            ("icons", "r.png") => ("image/png", IMG_ICON_R),
3021            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3022            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3023            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3024            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3025            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3026            _ => return StatusCode::NOT_FOUND.into_response(),
3027        };
3028    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3029}
3030
3031async fn preview_handler(
3032    State(state): State<AppState>,
3033    Query(query): Query<PreviewQuery>,
3034) -> impl IntoResponse {
3035    let raw_path = query
3036        .path
3037        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3038    let resolved = resolve_input_path(&raw_path);
3039
3040    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
3041    // binary whose working directory is not the project root), return a clear message
3042    // instead of an opaque OS error from build_preview_html.
3043    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3044        return Html(
3045            r#"<div class="preview-error">Sample directory not available on this server.
3046            Enter a path to a project directory or upload files using Browse.</div>"#
3047                .to_string(),
3048        );
3049    }
3050
3051    if state.server_mode {
3052        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3053        // Upload temp dirs and built-in sample/fixture paths are always safe to preview.
3054        if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3055            let config = &state.base_config;
3056            if config.discovery.allowed_scan_roots.is_empty() {
3057                return Html(
3058                    r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3059                );
3060            }
3061            let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3062                fs::canonicalize(root)
3063                    .ok()
3064                    .is_some_and(|r| canonical.starts_with(&r))
3065            });
3066            if !allowed {
3067                return Html(
3068                    r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3069                );
3070            }
3071        }
3072    }
3073
3074    let include_patterns = split_patterns(query.include_globs.as_deref());
3075    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3076
3077    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3078        Ok(html) => Html(html),
3079        Err(err) => Html(format!(
3080            r#"<div class="preview-error">Preview failed: {}</div>"#,
3081            escape_html(&err.to_string())
3082        )),
3083    }
3084}
3085
3086#[derive(Debug, Deserialize, Default)]
3087struct SuggestCoverageQuery {
3088    path: Option<String>,
3089}
3090
3091#[derive(Serialize)]
3092struct SuggestCoverageResponse {
3093    found: Option<String>,
3094    tool: Option<&'static str>,
3095    hint: Option<&'static str>,
3096}
3097
3098async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3099    const CANDIDATES: &[&str] = &[
3100        // LCOV — cargo-llvm-cov, gcov, lcov
3101        "coverage/lcov.info",
3102        "lcov.info",
3103        "target/llvm-cov/lcov.info",
3104        "target/coverage/lcov.info",
3105        "target/debug/coverage/lcov.info",
3106        "coverage/coverage.lcov",
3107        "build/coverage/lcov.info",
3108        "reports/lcov.info",
3109        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
3110        "coverage.xml",
3111        "coverage/coverage.xml",
3112        "target/site/cobertura/coverage.xml",
3113        "build/reports/coverage/coverage.xml",
3114        // JaCoCo XML — Gradle, Maven JaCoCo plugin
3115        "target/site/jacoco/jacoco.xml",
3116        "build/reports/jacoco/test/jacocoTestReport.xml",
3117        "build/reports/jacoco/jacocoTestReport.xml",
3118        "build/jacoco/jacoco.xml",
3119    ];
3120    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3121    let found = CANDIDATES
3122        .iter()
3123        .map(|rel| root.join(rel))
3124        .find(|p| p.is_file())
3125        .map(|p| display_path(&p));
3126
3127    let (tool, hint) = detect_coverage_tool(&root);
3128    Json(SuggestCoverageResponse { found, tool, hint })
3129}
3130
3131/// Inspect the project root for known build/package files and return the most likely coverage
3132/// tool name and the shell command needed to generate a coverage file.
3133fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3134    if root.join("Cargo.toml").is_file() {
3135        return (
3136            Some("cargo-llvm-cov"),
3137            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3138        );
3139    }
3140    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3141        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3142    }
3143    if root.join("pom.xml").is_file() {
3144        return (Some("jacoco"), Some("mvn test jacoco:report"));
3145    }
3146    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3147        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3148    }
3149    (None, None)
3150}
3151
3152/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
3153#[allow(clippy::result_large_err)]
3154fn validate_server_scan_path(
3155    config: &sloc_config::AppConfig,
3156    resolved_path: &Path,
3157    csp_nonce: &str,
3158) -> Result<(), Response> {
3159    if config.discovery.allowed_scan_roots.is_empty() {
3160        let template = ErrorTemplate {
3161            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3162                      Set allowed_scan_roots in the server config to permit scanning."
3163                .to_string(),
3164            last_report_url: None,
3165            last_report_label: None,
3166            csp_nonce: csp_nonce.to_owned(),
3167            version: env!("CARGO_PKG_VERSION"),
3168        };
3169        return Err((
3170            StatusCode::FORBIDDEN,
3171            Html(
3172                template
3173                    .render()
3174                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3175            ),
3176        )
3177            .into_response());
3178    }
3179    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3180    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3181        fs::canonicalize(root)
3182            .ok()
3183            .is_some_and(|r| canonical.starts_with(&r))
3184    });
3185    if !allowed {
3186        tracing::warn!(event = "path_rejected", path = %canonical.display(),
3187            "Scan path not in allowed_scan_roots");
3188        let template = ErrorTemplate {
3189            message: "The requested path is not within an allowed scan directory.".to_string(),
3190            last_report_url: None,
3191            last_report_label: None,
3192            csp_nonce: csp_nonce.to_owned(),
3193            version: env!("CARGO_PKG_VERSION"),
3194        };
3195        return Err((
3196            StatusCode::FORBIDDEN,
3197            Html(
3198                template
3199                    .render()
3200                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3201            ),
3202        )
3203            .into_response());
3204    }
3205    Ok(())
3206}
3207
3208/// Exclude the output directory from scanning so artifacts don't pollute counts.
3209fn apply_output_dir_exclusions(
3210    config: &mut sloc_config::AppConfig,
3211    project_path: &str,
3212    raw_output_dir: &str,
3213) {
3214    let project_root = resolve_input_path(project_path);
3215    let raw_out = raw_output_dir.trim();
3216    let resolved_out = if raw_out.is_empty() {
3217        project_root.join("sloc")
3218    } else if Path::new(raw_out).is_absolute() {
3219        PathBuf::from(raw_out)
3220    } else {
3221        workspace_root().join(raw_out)
3222    };
3223    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3224        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3225            let dir = first.to_string();
3226            if !config.discovery.excluded_directories.contains(&dir) {
3227                config.discovery.excluded_directories.push(dir);
3228            }
3229        }
3230    }
3231    if !config
3232        .discovery
3233        .excluded_directories
3234        .iter()
3235        .any(|d| d == "sloc")
3236    {
3237        config
3238            .discovery
3239            .excluded_directories
3240            .push("sloc".to_string());
3241    }
3242}
3243
3244/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
3245const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3246    ScanSummarySnapshot {
3247        files_analyzed: run.summary_totals.files_analyzed,
3248        files_skipped: run.summary_totals.files_skipped,
3249        total_physical_lines: run.summary_totals.total_physical_lines,
3250        code_lines: run.summary_totals.code_lines,
3251        comment_lines: run.summary_totals.comment_lines,
3252        blank_lines: run.summary_totals.blank_lines,
3253        functions: run.summary_totals.functions,
3254        classes: run.summary_totals.classes,
3255        variables: run.summary_totals.variables,
3256        imports: run.summary_totals.imports,
3257        test_count: run.summary_totals.test_count,
3258        coverage_lines_found: run.summary_totals.coverage_lines_found,
3259        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3260        coverage_functions_found: run.summary_totals.coverage_functions_found,
3261        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3262        coverage_branches_found: run.summary_totals.coverage_branches_found,
3263        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3264    }
3265}
3266
3267/// Build the `RegistryEntry` for the just-completed scan run.
3268pub(crate) fn build_run_registry_entry(
3269    run: &AnalysisRun,
3270    run_id: &str,
3271    project_label: &str,
3272    artifacts: &RunArtifacts,
3273) -> RegistryEntry {
3274    RegistryEntry {
3275        run_id: run_id.to_owned(),
3276        timestamp_utc: run.tool.timestamp_utc,
3277        project_label: project_label.to_owned(),
3278        input_roots: run.input_roots.clone(),
3279        json_path: artifacts.json_path.clone(),
3280        html_path: artifacts.html_path.clone(),
3281        pdf_path: artifacts.pdf_path.clone(),
3282        csv_path: artifacts.csv_path.clone(),
3283        xlsx_path: artifacts.xlsx_path.clone(),
3284        summary: summary_snapshot_from_run(run),
3285        git_branch: run.git_branch.clone(),
3286        git_commit: run.git_commit_short.clone(),
3287        git_author: run.git_commit_author.clone(),
3288        git_tags: run.git_tags.clone(),
3289        git_nearest_tag: run.git_nearest_tag.clone(),
3290        git_commit_date: run.git_commit_date.clone(),
3291    }
3292}
3293
3294/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
3295fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3296    if let Some(policy) = form.mixed_line_policy {
3297        config.analysis.mixed_line_policy = policy;
3298    }
3299    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3300    config.analysis.generated_file_detection =
3301        form.generated_file_detection.as_deref() != Some("disabled");
3302    config.analysis.minified_file_detection =
3303        form.minified_file_detection.as_deref() != Some("disabled");
3304    config.analysis.vendor_directory_detection =
3305        form.vendor_directory_detection.as_deref() != Some("disabled");
3306    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3307    if let Some(binary_behavior) = form.binary_file_behavior {
3308        config.analysis.binary_file_behavior = binary_behavior;
3309    }
3310    if let Some(report_title) = form.report_title.as_deref() {
3311        let trimmed = report_title.trim();
3312        if !trimmed.is_empty() {
3313            config.reporting.report_title = trimmed.to_string();
3314        }
3315    }
3316    if let Some(hf) = form.report_header_footer.as_deref() {
3317        let trimmed = hf.trim();
3318        config.reporting.report_header_footer = if trimmed.is_empty() {
3319            None
3320        } else {
3321            Some(trimmed.to_string())
3322        };
3323    }
3324    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3325    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3326    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3327    if let Some(policy) = form.continuation_line_policy {
3328        config.analysis.continuation_line_policy = policy;
3329    }
3330    if let Some(policy) = form.blank_in_block_comment_policy {
3331        config.analysis.blank_in_block_comment_policy = policy;
3332    }
3333    config.analysis.count_compiler_directives =
3334        form.count_compiler_directives.as_deref() != Some("disabled");
3335    if let Some(cov) = &form.coverage_file {
3336        let trimmed = cov.trim();
3337        if !trimmed.is_empty() {
3338            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
3339        }
3340    }
3341}
3342
3343/// Fire-and-forget: generate the PDF in a background task if one is pending.
3344/// On failure, clears `pdf_path` in the artifacts map so the results page shows
3345/// an error instead of spinning indefinitely.
3346fn spawn_pdf_background(
3347    pending_pdf: PendingPdf,
3348    run_id: String,
3349    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3350) {
3351    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
3352        tokio::spawn(async move {
3353            let result = tokio::task::spawn_blocking(move || {
3354                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
3355                if cleanup_src {
3356                    let _ = fs::remove_file(&pdf_src);
3357                }
3358                r
3359            })
3360            .await;
3361            let failed = match result {
3362                Ok(Ok(())) => false,
3363                Ok(Err(err)) => {
3364                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
3365                    true
3366                }
3367                Err(err) => {
3368                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
3369                    true
3370                }
3371            };
3372            if failed {
3373                let mut map = artifacts.lock().await;
3374                if let Some(entry) = map.get_mut(&run_id) {
3375                    entry.pdf_path = None;
3376                }
3377            }
3378        });
3379    }
3380}
3381
3382/// Sum the code lines added in this comparison (new + grown files).
3383fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3384    cmp.file_deltas
3385        .iter()
3386        .map(|f| match f.status {
3387            FileChangeStatus::Added => f.current_code,
3388            FileChangeStatus::Modified => f.code_delta.max(0),
3389            _ => 0,
3390        })
3391        .sum()
3392}
3393
3394/// Sum the code lines removed in this comparison (deleted + shrunk files).
3395fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3396    cmp.file_deltas
3397        .iter()
3398        .map(|f| match f.status {
3399            FileChangeStatus::Removed => f.baseline_code,
3400            FileChangeStatus::Modified => (-f.code_delta).max(0),
3401            _ => 0,
3402        })
3403        .sum()
3404}
3405
3406/// Build one `SubmoduleRow`, optionally generating and persisting a sub-report HTML file.
3407fn build_submodule_row(
3408    s: &sloc_core::SubmoduleSummary,
3409    run: &AnalysisRun,
3410    run_id: &str,
3411    run_dir: &Path,
3412    generate_html: bool,
3413) -> SubmoduleRow {
3414    let safe = sanitize_project_label(&s.name);
3415    let artifact_key = format!("sub_{safe}");
3416    let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
3417        let parent_path = run
3418            .input_roots
3419            .first()
3420            .map_or("", std::string::String::as_str);
3421        let sub_run = build_sub_run(run, s, parent_path);
3422        render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
3423            let path = run_dir.join(format!("{artifact_key}.html"));
3424            if fs::write(&path, sub_html.as_bytes()).is_ok() {
3425                Some(format!("/runs/{artifact_key}/{run_id}"))
3426            } else {
3427                None
3428            }
3429        })
3430    } else {
3431        None
3432    };
3433    SubmoduleRow {
3434        name: s.name.clone(),
3435        relative_path: s.relative_path.clone(),
3436        files_analyzed: s.files_analyzed,
3437        code_lines: s.code_lines,
3438        comment_lines: s.comment_lines,
3439        blank_lines: s.blank_lines,
3440        total_physical_lines: s.total_physical_lines,
3441        html_url,
3442    }
3443}
3444
3445// Immediately returns a wait page and runs the analysis in a background tokio task.
3446// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
3447#[allow(clippy::similar_names)]
3448#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
3449async fn analyze_handler(
3450    State(state): State<AppState>,
3451    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3452    Form(form): Form<AnalyzeForm>,
3453) -> impl IntoResponse {
3454    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
3455        let template = ErrorTemplate {
3456            message: format!(
3457                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
3458             Please wait a moment and try again."
3459            ),
3460            last_report_url: None,
3461            last_report_label: None,
3462            csp_nonce: csp_nonce.clone(),
3463            version: env!("CARGO_PKG_VERSION"),
3464        };
3465        return (
3466            StatusCode::SERVICE_UNAVAILABLE,
3467            Html(
3468                template
3469                    .render()
3470                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
3471            ),
3472        )
3473            .into_response();
3474    };
3475
3476    let mut config = state.base_config.clone();
3477
3478    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
3479    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
3480    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
3481
3482    if !is_git_mode {
3483        let resolved_path = resolve_input_path(&form.path);
3484        if state.server_mode
3485            && !is_upload_tmp_path(&resolved_path)
3486            && !is_sample_path(&resolved_path)
3487        {
3488            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
3489                return resp;
3490            }
3491        }
3492        config.discovery.root_paths = vec![resolved_path];
3493    }
3494
3495    apply_form_to_config(&mut config, &form);
3496    apply_output_dir_exclusions(
3497        &mut config,
3498        &form.path,
3499        form.output_dir.as_deref().unwrap_or(""),
3500    );
3501
3502    // Generate a wait_id now (before spawning) so the client can poll for status.
3503    let wait_id = uuid::Uuid::new_v4().to_string();
3504    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
3505
3506    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
3507    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
3508    let task_cancel = Arc::clone(&cancel_token);
3509
3510    // Register Running state before building the task struct so the semaphore permit
3511    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
3512    {
3513        let mut runs = state.async_runs.lock().await;
3514        runs.insert(
3515            wait_id.clone(),
3516            AsyncRunState::Running {
3517                started_at: std::time::Instant::now(),
3518                cancel_token,
3519            },
3520        );
3521    }
3522
3523    let task = AnalysisTask {
3524        sem_permit,
3525        state: state.clone(),
3526        wait_id: wait_id.clone(),
3527        config,
3528        cancel: task_cancel,
3529        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
3530        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
3531        generate_html: form.generate_html.is_some(),
3532        generate_pdf: form.generate_pdf.is_some(),
3533        project_path: form.path.clone(),
3534        // In server mode the client-supplied output_dir is ignored — artifacts are
3535        // always written under the server's configured output root so remote users
3536        // cannot direct writes to arbitrary filesystem paths.
3537        output_dir: if state.server_mode {
3538            None
3539        } else {
3540            form.output_dir.clone()
3541        },
3542        clones_dir: state.git_clones_dir.clone(),
3543    };
3544
3545    tokio::spawn(run_analysis_task(task));
3546
3547    let template = ScanWaitTemplate {
3548        version: env!("CARGO_PKG_VERSION"),
3549        wait_id_json,
3550        project_path: form.path.clone(),
3551        csp_nonce,
3552    };
3553    let html = template
3554        .render()
3555        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
3556    let mut response = Html(html).into_response();
3557    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
3558        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
3559            response.headers_mut().insert(name, val);
3560        }
3561    }
3562    response
3563}
3564
3565struct AnalysisTask {
3566    sem_permit: tokio::sync::OwnedSemaphorePermit,
3567    state: AppState,
3568    wait_id: String,
3569    config: AppConfig,
3570    cancel: Arc<std::sync::atomic::AtomicBool>,
3571    git_repo: Option<String>,
3572    git_ref: Option<String>,
3573    generate_html: bool,
3574    generate_pdf: bool,
3575    project_path: String,
3576    output_dir: Option<String>,
3577    clones_dir: PathBuf,
3578}
3579
3580#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
3581async fn run_analysis_task(task: AnalysisTask) {
3582    let _permit = task.sem_permit;
3583
3584    let cancel_sb = Arc::clone(&task.cancel);
3585    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
3586    let clones_dir_sb = task.clones_dir;
3587    // Save the upload staging path before config is moved into spawn_blocking.
3588    let upload_staging_root = task
3589        .config
3590        .discovery
3591        .root_paths
3592        .first()
3593        .filter(|p| is_upload_tmp_path(p))
3594        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
3595        .map(PathBuf::from);
3596    let config_sb = task.config;
3597    let analysis_result = tokio::task::spawn_blocking(move || {
3598        run_analysis_blocking(config_sb, git_repo_sb, git_ref_sb, clones_dir_sb, cancel_sb)
3599    })
3600    .await
3601    .map_err(|err| anyhow::anyhow!(err.to_string()))
3602    .and_then(|result| result);
3603
3604    // If cancelled while running, discard results and mark as cancelled.
3605    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
3606        let mut runs = task.state.async_runs.lock().await;
3607        // Only overwrite if still Running (don't clobber a Complete that snuck in).
3608        if matches!(
3609            runs.get(&task.wait_id),
3610            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
3611        ) {
3612            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
3613        }
3614        drop(runs);
3615        return;
3616    }
3617
3618    let (run, report_html) = match analysis_result {
3619        Ok(v) => v,
3620        Err(err) => {
3621            // Distinguish user-cancelled from real failure.
3622            if err.to_string().contains("analysis cancelled") {
3623                let mut runs = task.state.async_runs.lock().await;
3624                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
3625                drop(runs);
3626                return;
3627            }
3628            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
3629            let mut runs = task.state.async_runs.lock().await;
3630            runs.insert(
3631                task.wait_id.clone(),
3632                AsyncRunState::Failed {
3633                    message: "Analysis failed. Check that the path exists and is readable."
3634                        .to_string(),
3635                },
3636            );
3637            drop(runs);
3638            return;
3639        }
3640    };
3641
3642    let run_id = run.tool.run_id.clone();
3643    tracing::info!(event = "scan_complete", run_id = %run_id,
3644        path = %task.project_path, files = run.summary_totals.files_analyzed,
3645        "Analysis finished");
3646
3647    let prev_entry: Option<RegistryEntry> = {
3648        let reg = task.state.registry.lock().await;
3649        reg.entries_for_roots(&run.input_roots)
3650            .into_iter()
3651            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3652            .cloned()
3653    };
3654
3655    let scan_delta = prev_entry.as_ref().and_then(|prev| {
3656        prev.json_path
3657            .as_ref()
3658            .and_then(|p| read_json(p).ok())
3659            .map(|prev_run| compute_delta(&prev_run, &run))
3660    });
3661    let prev_scan_count: usize = {
3662        let reg = task.state.registry.lock().await;
3663        reg.entries_for_roots(&run.input_roots)
3664            .iter()
3665            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
3666            .count()
3667    };
3668
3669    let output_root = resolve_output_root(task.output_dir.as_deref());
3670    let project_label = derive_project_label(
3671        task.git_repo.as_deref(),
3672        task.git_ref.as_deref(),
3673        &task.project_path,
3674    );
3675    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
3676    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
3677
3678    let result_context = RunResultContext {
3679        prev_entry: prev_entry.clone(),
3680        prev_scan_count,
3681        project_path: task.project_path.clone(),
3682    };
3683
3684    let artifact_result = persist_run_artifacts(
3685        &run,
3686        &report_html,
3687        &run_dir,
3688        true,
3689        task.generate_html,
3690        task.generate_pdf,
3691        &run.effective_configuration.reporting.report_title,
3692        &file_stem,
3693        result_context,
3694    );
3695
3696    let (artifacts, pending_pdf) = match artifact_result {
3697        Ok(v) => v,
3698        Err(err) => {
3699            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
3700            let mut runs = task.state.async_runs.lock().await;
3701            runs.insert(
3702                task.wait_id.clone(),
3703                AsyncRunState::Failed {
3704                    message: "Failed to save report artifacts. Check available disk space."
3705                        .to_string(),
3706                },
3707            );
3708            drop(runs);
3709            return;
3710        }
3711    };
3712
3713    {
3714        let mut map = task.state.artifacts.lock().await;
3715        map.insert(run_id.clone(), artifacts.clone());
3716    }
3717
3718    {
3719        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
3720        let mut reg = task.state.registry.lock().await;
3721        reg.add_entry(entry);
3722        let _ = reg.save(&task.state.registry_path);
3723    }
3724
3725    if let Some(ref cfg_path) = artifacts.scan_config_path {
3726        save_scan_config_json(
3727            cfg_path,
3728            &run,
3729            &task.project_path,
3730            task.output_dir.as_deref(),
3731            task.generate_html,
3732            task.generate_pdf,
3733        );
3734    }
3735
3736    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
3737
3738    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
3739    let mut runs = task.state.async_runs.lock().await;
3740    runs.insert(
3741        task.wait_id.clone(),
3742        AsyncRunState::Complete {
3743            run_id: run_id.clone(),
3744        },
3745    );
3746    drop(runs);
3747
3748    // Remove the client-upload staging directory after a successful scan so
3749    // that uploaded project files don't accumulate in the OS temp directory.
3750    if let Some(staging) = upload_staging_root {
3751        let _ = tokio::fs::remove_dir_all(staging).await;
3752    }
3753
3754    let _ = scan_delta;
3755}
3756
3757fn save_scan_config_json(
3758    cfg_path: &std::path::Path,
3759    run: &sloc_core::AnalysisRun,
3760    project_path: &str,
3761    output_dir: Option<&str>,
3762    generate_html: bool,
3763    generate_pdf: bool,
3764) {
3765    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
3766        .ok()
3767        .and_then(|v| v.as_str().map(String::from))
3768        .unwrap_or_else(|| "code_only".to_string());
3769    let behavior_str =
3770        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
3771            .ok()
3772            .and_then(|v| v.as_str().map(String::from))
3773            .unwrap_or_else(|| "skip".to_string());
3774    let scan_cfg = ScanConfig {
3775        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
3776        path: project_path.to_string(),
3777        include_globs: run
3778            .effective_configuration
3779            .discovery
3780            .include_globs
3781            .join("\n"),
3782        exclude_globs: run
3783            .effective_configuration
3784            .discovery
3785            .exclude_globs
3786            .join("\n"),
3787        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
3788        mixed_line_policy: policy_str,
3789        python_docstrings_as_comments: run
3790            .effective_configuration
3791            .analysis
3792            .python_docstrings_as_comments,
3793        generated_file_detection: run
3794            .effective_configuration
3795            .analysis
3796            .generated_file_detection,
3797        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
3798        vendor_directory_detection: run
3799            .effective_configuration
3800            .analysis
3801            .vendor_directory_detection,
3802        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
3803        binary_file_behavior: behavior_str,
3804        output_dir: output_dir.unwrap_or("").to_string(),
3805        report_title: run.effective_configuration.reporting.report_title.clone(),
3806        generate_html,
3807        generate_pdf,
3808    };
3809    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3810        let _ = std::fs::write(cfg_path, json);
3811    }
3812}
3813
3814#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
3815fn run_analysis_blocking(
3816    mut config: AppConfig,
3817    git_repo: Option<String>,
3818    git_ref: Option<String>,
3819    clones_dir: PathBuf,
3820    cancel: Arc<std::sync::atomic::AtomicBool>,
3821) -> Result<(sloc_core::AnalysisRun, String)> {
3822    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
3823        let dest = git_clone_dest(&repo, &clones_dir);
3824        sloc_git::clone_or_fetch(&repo, &dest)?;
3825        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3826        sloc_git::create_worktree(&dest, &refname, &wt)?;
3827        config.discovery.root_paths = vec![wt.clone()];
3828        let run = analyze(&config, "serve", Some(&cancel));
3829        let _ = sloc_git::destroy_worktree(&dest, &wt);
3830        let mut run = run?;
3831        if run.git_branch.is_none() {
3832            run.git_branch = Some(refname);
3833        }
3834        let html = render_html(&run)?;
3835        return Ok((run, html));
3836    }
3837    let run = analyze(&config, "serve", Some(&cancel))?;
3838    let html = render_html(&run)?;
3839    Ok((run, html))
3840}
3841
3842fn derive_project_label(
3843    git_repo: Option<&str>,
3844    git_ref: Option<&str>,
3845    fallback_path: &str,
3846) -> String {
3847    match (
3848        git_repo.filter(|s| !s.is_empty()),
3849        git_ref.filter(|s| !s.is_empty()),
3850    ) {
3851        (Some(repo), Some(refname)) => {
3852            let repo_name = repo
3853                .trim_end_matches('/')
3854                .trim_end_matches(".git")
3855                .rsplit('/')
3856                .next()
3857                .unwrap_or("repo");
3858            sanitize_project_label(&format!("{repo_name}_{refname}"))
3859        }
3860        _ => sanitize_project_label(fallback_path),
3861    }
3862}
3863
3864fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
3865    let commit = commit_short.unwrap_or("").trim();
3866    if commit.is_empty() {
3867        project_label.to_string()
3868    } else {
3869        format!("{project_label}_{commit}")
3870    }
3871}
3872
3873// ── Async scan status + result handlers ──────────────────────────────────────
3874
3875#[derive(Serialize)]
3876#[serde(tag = "state", rename_all = "snake_case")]
3877enum AsyncRunStatusResponse {
3878    Running { elapsed_secs: u64 },
3879    Complete { run_id: String },
3880    Failed { message: String },
3881    Cancelled,
3882}
3883
3884async fn async_run_status_handler(
3885    State(state): State<AppState>,
3886    AxumPath(wait_id): AxumPath<String>,
3887) -> Response {
3888    // wait_id comes from our own UUID generator; reject any structurally malformed value.
3889    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3890        return error::bad_request("invalid wait_id");
3891    }
3892    let run_state = {
3893        let runs = state.async_runs.lock().await;
3894        runs.get(&wait_id).cloned()
3895    };
3896    match run_state {
3897        None => error::not_found("run not found"),
3898        Some(AsyncRunState::Running { started_at, .. }) => {
3899            // Treat runs older than 2 h as timed out (analysis should finish well under that).
3900            if started_at.elapsed() > std::time::Duration::from_hours(2) {
3901                let mut runs = state.async_runs.lock().await;
3902                runs.insert(
3903                    wait_id,
3904                    AsyncRunState::Failed {
3905                        message: "Analysis timed out after 2 hours.".to_string(),
3906                    },
3907                );
3908                drop(runs);
3909                return Json(AsyncRunStatusResponse::Failed {
3910                    message: "Analysis timed out after 2 hours.".to_string(),
3911                })
3912                .into_response();
3913            }
3914            Json(AsyncRunStatusResponse::Running {
3915                elapsed_secs: started_at.elapsed().as_secs(),
3916            })
3917            .into_response()
3918        }
3919        Some(AsyncRunState::Complete { run_id }) => {
3920            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
3921        }
3922        Some(AsyncRunState::Failed { message }) => {
3923            Json(AsyncRunStatusResponse::Failed { message }).into_response()
3924        }
3925        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
3926    }
3927}
3928
3929async fn cancel_run_handler(
3930    State(state): State<AppState>,
3931    AxumPath(wait_id): AxumPath<String>,
3932) -> Response {
3933    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3934        return error::bad_request("invalid wait_id");
3935    }
3936    let mut runs = state.async_runs.lock().await;
3937    let resp = match runs.get(&wait_id) {
3938        Some(AsyncRunState::Running { cancel_token, .. }) => {
3939            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
3940            runs.insert(wait_id, AsyncRunState::Cancelled);
3941            StatusCode::OK.into_response()
3942        }
3943        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
3944        _ => error::not_found("run not found"),
3945    };
3946    drop(runs);
3947    resp
3948}
3949
3950async fn async_run_result_handler(
3951    State(state): State<AppState>,
3952    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3953    AxumPath(run_id): AxumPath<String>,
3954) -> Response {
3955    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
3956        return StatusCode::BAD_REQUEST.into_response();
3957    }
3958
3959    let artifacts = {
3960        let map = state.artifacts.lock().await;
3961        map.get(&run_id).cloned()
3962    };
3963    let artifacts = if let Some(a) = artifacts {
3964        a
3965    } else {
3966        let reg = state.registry.lock().await;
3967        if let Some(entry) = reg.find_by_run_id(&run_id) {
3968            recover_artifacts_from_registry(entry)
3969        } else {
3970            let html = ErrorTemplate {
3971                message: format!(
3972                    "Report not found. Run ID {} is not in the scan history.",
3973                    &run_id[..run_id.len().min(8)]
3974                ),
3975                last_report_url: Some("/view-reports".to_string()),
3976                last_report_label: Some("View Reports".to_string()),
3977                csp_nonce: csp_nonce.clone(),
3978                version: env!("CARGO_PKG_VERSION"),
3979            }
3980            .render()
3981            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3982            return (StatusCode::NOT_FOUND, Html(html)).into_response();
3983        }
3984    };
3985
3986    let json_path = if let Some(p) = &artifacts.json_path {
3987        p.clone()
3988    } else {
3989        let html = ErrorTemplate {
3990            message: "JSON result was not saved for this run.".to_string(),
3991            last_report_url: Some("/view-reports".to_string()),
3992            last_report_label: Some("View Reports".to_string()),
3993            csp_nonce: csp_nonce.clone(),
3994            version: env!("CARGO_PKG_VERSION"),
3995        }
3996        .render()
3997        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
3998        return (StatusCode::NOT_FOUND, Html(html)).into_response();
3999    };
4000
4001    let Ok(run) = read_json(&json_path) else {
4002        let folder_hint = json_path
4003            .parent()
4004            .map(|p| p.display().to_string())
4005            .unwrap_or_default();
4006        let redirect_url = format!("/runs/result/{run_id}");
4007        return missing_scan_relocate_response(
4008            &format!(
4009                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
4010                 deleted. Browse to the folder containing your scan output to reconnect it.",
4011                json_path.display()
4012            ),
4013            &run_id,
4014            &folder_hint,
4015            &redirect_url,
4016            state.server_mode,
4017            &csp_nonce,
4018        );
4019    };
4020
4021    let confluence_configured = {
4022        let store = state.confluence.lock().await;
4023        store.is_configured()
4024    };
4025
4026    render_result_page(
4027        &run,
4028        &artifacts,
4029        &run_id,
4030        &csp_nonce,
4031        confluence_configured,
4032        state.server_mode,
4033    )
4034}
4035
4036#[allow(clippy::too_many_lines)]
4037#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
4038fn render_result_page(
4039    run: &AnalysisRun,
4040    artifacts: &RunArtifacts,
4041    run_id: &str,
4042    csp_nonce: &str,
4043    confluence_configured: bool,
4044    server_mode: bool,
4045) -> Response {
4046    let ctx = &artifacts.result_context;
4047    let prev_entry = &ctx.prev_entry;
4048    let prev_scan_count = ctx.prev_scan_count;
4049    let project_path = &ctx.project_path;
4050
4051    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4052        prev.json_path
4053            .as_ref()
4054            .and_then(|p| read_json(p).ok())
4055            .map(|prev_run| compute_delta(&prev_run, run))
4056    });
4057
4058    let files_analyzed = run.per_file_records.len() as u64;
4059    let files_skipped = run.skipped_file_records.len() as u64;
4060    let physical_lines = run
4061        .totals_by_language
4062        .iter()
4063        .map(|r| r.total_physical_lines)
4064        .sum::<u64>();
4065    let code_lines = run
4066        .totals_by_language
4067        .iter()
4068        .map(|r| r.code_lines)
4069        .sum::<u64>();
4070    let comment_lines = run
4071        .totals_by_language
4072        .iter()
4073        .map(|r| r.comment_lines)
4074        .sum::<u64>();
4075    let blank_lines = run
4076        .totals_by_language
4077        .iter()
4078        .map(|r| r.blank_lines)
4079        .sum::<u64>();
4080    let mixed_lines = run
4081        .totals_by_language
4082        .iter()
4083        .map(|r| r.mixed_lines_separate)
4084        .sum::<u64>();
4085    let functions = run
4086        .totals_by_language
4087        .iter()
4088        .map(|r| r.functions)
4089        .sum::<u64>();
4090    let classes = run
4091        .totals_by_language
4092        .iter()
4093        .map(|r| r.classes)
4094        .sum::<u64>();
4095    let variables = run
4096        .totals_by_language
4097        .iter()
4098        .map(|r| r.variables)
4099        .sum::<u64>();
4100    let imports = run
4101        .totals_by_language
4102        .iter()
4103        .map(|r| r.imports)
4104        .sum::<u64>();
4105
4106    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4107    let prev_fa = prev_sum.map(|s| s.files_analyzed);
4108    let prev_fs = prev_sum.map(|s| s.files_skipped);
4109    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4110    let prev_cl = prev_sum.map(|s| s.code_lines);
4111    let prev_cml = prev_sum.map(|s| s.comment_lines);
4112    let prev_bl = prev_sum.map(|s| s.blank_lines);
4113    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4114    let prev_fa_str = fmt_prev(prev_fa);
4115    let prev_fs_str = fmt_prev(prev_fs);
4116    let prev_pl_str = fmt_prev(prev_pl);
4117    let prev_cl_str = fmt_prev(prev_cl);
4118    let prev_cml_str = fmt_prev(prev_cml);
4119    let prev_bl_str = fmt_prev(prev_bl);
4120    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4121    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4122    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4123    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4124    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4125    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4126    let delta_fa_class = delta_fa_class.to_string();
4127    let delta_fs_class = delta_fs_class.to_string();
4128    let delta_pl_class = delta_pl_class.to_string();
4129    let delta_cl_class = delta_cl_class.to_string();
4130    let delta_cml_class = delta_cml_class.to_string();
4131    let delta_bl_class = delta_bl_class.to_string();
4132
4133    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4134    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4135    let (delta_lines_net_str, delta_lines_net_class) =
4136        match (delta_lines_added, delta_lines_removed) {
4137            (Some(a), Some(r)) => {
4138                let net = a - r;
4139                (fmt_delta(net), delta_class(net).to_string())
4140            }
4141            _ => ("—".to_string(), "na".to_string()),
4142        };
4143
4144    let run_dir = artifacts.output_dir.clone();
4145    let git_branch = run.git_branch.clone();
4146    let git_commit = run.git_commit_short.clone();
4147    let git_commit_long = run.git_commit_long.clone();
4148    let git_author = run.git_commit_author.clone();
4149    let scan_performed_by = format!(
4150        "{} / {}",
4151        run.environment.initiator_username, run.environment.initiator_hostname
4152    );
4153    let scan_time_display = fmt_la_time(run.tool.timestamp_utc);
4154    let os_display = format!(
4155        "{} / {}",
4156        run.environment.operating_system, run.environment.architecture
4157    );
4158    let test_count = run.summary_totals.test_count;
4159
4160    let template = ResultTemplate {
4161        version: env!("CARGO_PKG_VERSION"),
4162        report_title: run.effective_configuration.reporting.report_title.clone(),
4163        project_path: project_path.clone(),
4164        output_dir: display_path(&artifacts.output_dir),
4165        run_id: run_id.to_owned(),
4166        run_id_short: run_id
4167            .split('-')
4168            .next_back()
4169            .unwrap_or(run_id)
4170            .chars()
4171            .take(7)
4172            .collect(),
4173        files_analyzed,
4174        files_skipped,
4175        physical_lines,
4176        code_lines,
4177        comment_lines,
4178        blank_lines,
4179        mixed_lines,
4180        functions,
4181        classes,
4182        variables,
4183        imports,
4184        html_url: artifacts
4185            .html_path
4186            .as_ref()
4187            .map(|_| format!("/runs/html/{run_id}")),
4188        pdf_url: artifacts
4189            .pdf_path
4190            .as_ref()
4191            .map(|_| format!("/runs/pdf/{run_id}")),
4192        json_url: artifacts
4193            .json_path
4194            .as_ref()
4195            .map(|_| format!("/runs/json/{run_id}")),
4196        html_download_url: artifacts
4197            .html_path
4198            .as_ref()
4199            .map(|_| format!("/runs/html/{run_id}?download=1")),
4200        pdf_download_url: artifacts
4201            .pdf_path
4202            .as_ref()
4203            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4204        json_download_url: artifacts
4205            .json_path
4206            .as_ref()
4207            .map(|_| format!("/runs/json/{run_id}?download=1")),
4208        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4209        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4210        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4211        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4212        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4213        prev_fa_str,
4214        prev_fs_str,
4215        prev_pl_str,
4216        prev_cl_str,
4217        prev_cml_str,
4218        prev_bl_str,
4219        delta_fa_str,
4220        delta_fa_class,
4221        delta_fs_str,
4222        delta_fs_class,
4223        delta_pl_str,
4224        delta_pl_class,
4225        delta_cl_str,
4226        delta_cl_class,
4227        delta_cml_str,
4228        delta_cml_class,
4229        delta_bl_str,
4230        delta_bl_class,
4231        delta_lines_added,
4232        delta_lines_removed,
4233        delta_lines_net_str,
4234        delta_lines_net_class,
4235        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4236        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4237        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4238        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4239        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4240            d.file_deltas
4241                .iter()
4242                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4243                .map(|f| {
4244                    #[allow(clippy::cast_sign_loss)]
4245                    let n = f.current_code as u64;
4246                    n
4247                })
4248                .sum()
4249        }),
4250        git_branch,
4251        git_commit,
4252        git_commit_long,
4253        git_author,
4254        scan_performed_by,
4255        scan_time_display,
4256        os_display,
4257        test_count,
4258        current_scan_number: prev_scan_count + 1,
4259        prev_scan_count,
4260        submodule_rows: run
4261            .submodule_summaries
4262            .iter()
4263            .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
4264            .collect(),
4265        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4266        scan_config_url: format!("/runs/scan-config/{run_id}"),
4267        lang_chart_json: {
4268            let entries: Vec<String> = run
4269                .totals_by_language
4270                .iter()
4271                .take(12)
4272                .map(|l| {
4273                    let name = l
4274                        .language
4275                        .display_name()
4276                        .replace('\\', "\\\\")
4277                        .replace('"', "\\\"");
4278                    format!(
4279                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4280                        name,
4281                        l.code_lines,
4282                        l.comment_lines,
4283                        l.blank_lines,
4284                        l.functions,
4285                        l.classes,
4286                        l.variables,
4287                        l.imports,
4288                        l.files,
4289                    )
4290                })
4291                .collect();
4292            format!("[{}]", entries.join(","))
4293        },
4294        scatter_chart_json: {
4295            let entries: Vec<String> = run
4296                .totals_by_language
4297                .iter()
4298                .map(|l| {
4299                    let name = l
4300                        .language
4301                        .display_name()
4302                        .replace('\\', "\\\\")
4303                        .replace('"', "\\\"");
4304                    format!(
4305                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4306                        name, l.files, l.code_lines, l.total_physical_lines,
4307                    )
4308                })
4309                .collect();
4310            format!("[{}]", entries.join(","))
4311        },
4312        semantic_chart_json: {
4313            let entries: Vec<String> = run
4314                .totals_by_language
4315                .iter()
4316                .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
4317                .map(|l| {
4318                    let name = l
4319                        .language
4320                        .display_name()
4321                        .replace('\\', "\\\\")
4322                        .replace('"', "\\\"");
4323                    format!(
4324                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
4325                        name, l.functions, l.classes, l.variables, l.imports,
4326                    )
4327                })
4328                .collect();
4329            format!("[{}]", entries.join(","))
4330        },
4331        submodule_chart_json: {
4332            let entries: Vec<String> = run
4333                .submodule_summaries
4334                .iter()
4335                .map(|s| {
4336                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
4337                    format!(
4338                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
4339                        name,
4340                        s.code_lines,
4341                        s.comment_lines,
4342                        s.blank_lines,
4343                        s.total_physical_lines,
4344                        s.files_analyzed,
4345                    )
4346                })
4347                .collect();
4348            format!("[{}]", entries.join(","))
4349        },
4350        has_submodule_data: !run.submodule_summaries.is_empty(),
4351        has_semantic_data: run
4352            .totals_by_language
4353            .iter()
4354            .any(|l| l.functions > 0 || l.classes > 0),
4355        csp_nonce: csp_nonce.to_owned(),
4356        confluence_configured,
4357        server_mode,
4358        report_header_footer: run
4359            .effective_configuration
4360            .reporting
4361            .report_header_footer
4362            .clone(),
4363    };
4364
4365    Html(
4366        template
4367            .render()
4368            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4369    )
4370    .into_response()
4371}
4372
4373fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
4374    let slug: String = report_title
4375        .chars()
4376        .map(|c| {
4377            if c.is_alphanumeric() || c == '-' {
4378                c.to_ascii_lowercase()
4379            } else {
4380                '_'
4381            }
4382        })
4383        .collect::<String>()
4384        .split('_')
4385        .filter(|s| !s.is_empty())
4386        .collect::<Vec<_>>()
4387        .join("_");
4388
4389    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
4390
4391    if slug.is_empty() {
4392        format!("report_{short_id}.pdf")
4393    } else {
4394        format!("{slug}_{short_id}.pdf")
4395    }
4396}
4397
4398#[derive(Serialize)]
4399struct PdfStatusResponse {
4400    ready: bool,
4401}
4402
4403/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
4404/// Clients poll this to update the button state without page reloads.
4405async fn pdf_status_handler(
4406    State(state): State<AppState>,
4407    AxumPath(run_id): AxumPath<String>,
4408) -> Response {
4409    let pdf_path = {
4410        let registry = state.artifacts.lock().await;
4411        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
4412    };
4413    let pdf_path = if pdf_path.is_some() {
4414        pdf_path
4415    } else {
4416        let reg = state.registry.lock().await;
4417        reg.find_by_run_id(&run_id)
4418            .map(recover_artifacts_from_registry)
4419            .and_then(|a| a.pdf_path)
4420    };
4421    let ready = pdf_path.is_some_and(|p| p.exists());
4422    Json(PdfStatusResponse { ready }).into_response()
4423}
4424
4425/// GET /`api/runs/:run_id/bundle`
4426///
4427/// Streams a gzip-compressed tar archive containing every artifact in the run's
4428/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
4429/// is built in memory so it never touches a temp file.
4430async fn download_bundle_handler(
4431    State(state): State<AppState>,
4432    AxumPath(run_id): AxumPath<String>,
4433) -> Response {
4434    // Resolve output directory from in-memory cache or persisted registry.
4435    let output_dir = {
4436        let cache = state.artifacts.lock().await;
4437        cache.get(&run_id).map(|a| a.output_dir.clone())
4438    };
4439    let output_dir = if let Some(d) = output_dir {
4440        d
4441    } else {
4442        let reg = state.registry.lock().await;
4443        match reg.find_by_run_id(&run_id) {
4444            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
4445            None => {
4446                return (
4447                    StatusCode::NOT_FOUND,
4448                    Json(serde_json::json!({"error": "Run not found"})),
4449                )
4450                    .into_response();
4451            }
4452        }
4453    };
4454
4455    if !output_dir.exists() {
4456        return (
4457            StatusCode::NOT_FOUND,
4458            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
4459        )
4460            .into_response();
4461    }
4462
4463    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
4464    let run_id_clone = run_id.clone();
4465    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
4466        use flate2::{write::GzEncoder, Compression};
4467        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
4468        {
4469            let mut tar = tar::Builder::new(&mut enc);
4470            tar.follow_symlinks(false);
4471            // Append every regular file in the output directory, skipping
4472            // sub-directories (the output dir is always flat).
4473            if let Ok(entries) = std::fs::read_dir(&output_dir) {
4474                for entry in entries.filter_map(Result::ok) {
4475                    let p = entry.path();
4476                    if p.is_file() {
4477                        let name = p.file_name().unwrap_or_default().to_string_lossy();
4478                        let archive_path = format!("{run_id_clone}/{name}");
4479                        tar.append_path_with_name(&p, &archive_path)?;
4480                    }
4481                }
4482            }
4483            tar.finish()?;
4484        }
4485        Ok(enc.finish()?)
4486    })
4487    .await;
4488
4489    match archive_result {
4490        Ok(Ok(bytes)) => {
4491            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
4492            axum::response::Response::builder()
4493                .status(StatusCode::OK)
4494                .header("Content-Type", "application/gzip")
4495                .header(
4496                    "Content-Disposition",
4497                    format!("attachment; filename=\"{filename}\""),
4498                )
4499                .header("Content-Length", bytes.len().to_string())
4500                .body(axum::body::Body::from(bytes))
4501                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
4502        }
4503        Ok(Err(e)) => (
4504            StatusCode::INTERNAL_SERVER_ERROR,
4505            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
4506        )
4507            .into_response(),
4508        Err(e) => (
4509            StatusCode::INTERNAL_SERVER_ERROR,
4510            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
4511        )
4512            .into_response(),
4513    }
4514}
4515
4516/// DELETE /`api/runs/:run_id`
4517///
4518/// Removes all on-disk artifacts for the run and purges the run from the
4519/// in-memory cache and the persisted registry. Returns 204 on success.
4520async fn delete_run_handler(
4521    State(state): State<AppState>,
4522    AxumPath(run_id): AxumPath<String>,
4523) -> Response {
4524    // Resolve output directory.
4525    let output_dir = {
4526        let mut cache = state.artifacts.lock().await;
4527        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
4528        cache.remove(&run_id);
4529        dir
4530    };
4531    let output_dir = if let Some(d) = output_dir {
4532        d
4533    } else {
4534        let reg = state.registry.lock().await;
4535        reg.find_by_run_id(&run_id)
4536            .map(|e| recover_artifacts_from_registry(e).output_dir)
4537            .unwrap_or_default()
4538    };
4539
4540    // Remove from persisted registry.
4541    {
4542        let mut reg = state.registry.lock().await;
4543        reg.entries.retain(|e| e.run_id != run_id);
4544        let _ = reg.save(&state.registry_path);
4545    }
4546
4547    // Delete on-disk artifacts.
4548    if output_dir.exists() {
4549        if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
4550            return (
4551                StatusCode::INTERNAL_SERVER_ERROR,
4552                Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
4553            )
4554                .into_response();
4555        }
4556    }
4557
4558    StatusCode::NO_CONTENT.into_response()
4559}
4560
4561/// POST /api/runs/cleanup
4562///
4563/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
4564/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
4565async fn cleanup_runs_handler(
4566    State(state): State<AppState>,
4567    Json(body): Json<serde_json::Value>,
4568) -> Response {
4569    let days = body
4570        .get("older_than_days")
4571        .and_then(serde_json::Value::as_u64)
4572        .unwrap_or(30)
4573        .max(1);
4574
4575    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
4576
4577    // Collect expired entries from the registry.
4578    let expired: Vec<(String, PathBuf)> = {
4579        let reg = state.registry.lock().await;
4580        reg.entries
4581            .iter()
4582            .filter(|e| e.timestamp_utc < cutoff)
4583            .map(|e| {
4584                let arts = recover_artifacts_from_registry(e);
4585                (e.run_id.clone(), arts.output_dir)
4586            })
4587            .collect()
4588    };
4589
4590    let mut deleted = 0usize;
4591    for (run_id, output_dir) in &expired {
4592        // Remove from in-memory cache.
4593        state.artifacts.lock().await.remove(run_id);
4594        // Delete on-disk artifacts (non-fatal if already gone).
4595        if output_dir.exists() {
4596            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
4597                eprintln!(
4598                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
4599                    output_dir.display()
4600                );
4601                continue;
4602            }
4603        }
4604        deleted += 1;
4605    }
4606
4607    // Purge expired run IDs from the registry in one pass.
4608    let expired_ids: std::collections::HashSet<&str> =
4609        expired.iter().map(|(id, _)| id.as_str()).collect();
4610    {
4611        let mut reg = state.registry.lock().await;
4612        reg.entries
4613            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
4614        let _ = reg.save(&state.registry_path);
4615    }
4616
4617    Json(serde_json::json!({ "deleted": deleted })).into_response()
4618}
4619
4620/// Serve the HTML artifact for a run — view or download.
4621/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
4622/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
4623/// current-request Content-Security-Policy nonce check.
4624fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
4625    // Find the first nonce value that was baked in at render time.
4626    let Some(start) = html.find("nonce=\"") else {
4627        // Reports generated before nonce support was added have bare <style> and <script>
4628        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
4629        // the inline blocks — without it the browser blocks all CSS and JS.
4630        return html
4631            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
4632            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
4633    };
4634    let value_start = start + 7; // len(r#"nonce=""#) == 7
4635    let Some(end_offset) = html[value_start..].find('"') else {
4636        return html.to_owned();
4637    };
4638    let old_nonce = &html[value_start..value_start + end_offset];
4639    html.replace(
4640        &format!("nonce=\"{old_nonce}\""),
4641        &format!("nonce=\"{new_nonce}\""),
4642    )
4643}
4644
4645fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4646    match fs::read_to_string(path) {
4647        Ok(raw) => {
4648            // Patch the saved nonce so inline styles/scripts pass CSP.
4649            let content = patch_html_nonce(&raw, csp_nonce);
4650            if wants_download {
4651                (
4652                    [
4653                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
4654                        (
4655                            header::CONTENT_DISPOSITION,
4656                            "attachment; filename=report.html",
4657                        ),
4658                    ],
4659                    content,
4660                )
4661                    .into_response()
4662            } else {
4663                Html(content).into_response()
4664            }
4665        }
4666        Err(err) => {
4667            let filename = path.file_name().map_or_else(
4668                || "report.html".to_string(),
4669                |n| n.to_string_lossy().into_owned(),
4670            );
4671            let msg = format!(
4672                "HTML report '{filename}' could not be read.\n\n\
4673                 Error: {err}\n\n\
4674                 If you moved or renamed the output folder, the stored path is now stale. \
4675                 Use 'Open HTML folder' from the results page to browse the output directory."
4676            );
4677            let html = ErrorTemplate {
4678                message: msg,
4679                last_report_url: Some("/view-reports".to_string()),
4680                last_report_label: Some("View Reports".to_string()),
4681                csp_nonce: csp_nonce.to_owned(),
4682                version: env!("CARGO_PKG_VERSION"),
4683            }
4684            .render()
4685            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4686            (StatusCode::NOT_FOUND, Html(html)).into_response()
4687        }
4688    }
4689}
4690
4691/// Serve the PDF artifact for a run — inline or download.
4692fn serve_pdf_artifact(
4693    path: &Path,
4694    report_title: &str,
4695    run_id: &str,
4696    wants_download: bool,
4697    csp_nonce: &str,
4698) -> Response {
4699    match fs::read(path) {
4700        Ok(bytes) => {
4701            let filename = build_pdf_filename(report_title, run_id);
4702            let disposition = if wants_download {
4703                format!("attachment; filename=\"{filename}\"")
4704            } else {
4705                format!("inline; filename=\"{filename}\"")
4706            };
4707            (
4708                [
4709                    (header::CONTENT_TYPE, "application/pdf".to_string()),
4710                    (header::CONTENT_DISPOSITION, disposition),
4711                ],
4712                bytes,
4713            )
4714                .into_response()
4715        }
4716        Err(err) => {
4717            let filename = path.file_name().map_or_else(
4718                || "report.pdf".to_string(),
4719                |n| n.to_string_lossy().into_owned(),
4720            );
4721            let msg = format!(
4722                "PDF report '{filename}' could not be read.\n\n\
4723                 Error: {err}\n\n\
4724                 If you moved or renamed the output folder, the stored path is now stale. \
4725                 Use 'Open PDF folder' from the results page to browse the output directory."
4726            );
4727            let html = ErrorTemplate {
4728                message: msg,
4729                last_report_url: Some("/view-reports".to_string()),
4730                last_report_label: Some("View Reports".to_string()),
4731                csp_nonce: csp_nonce.to_owned(),
4732                version: env!("CARGO_PKG_VERSION"),
4733            }
4734            .render()
4735            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4736            (StatusCode::NOT_FOUND, Html(html)).into_response()
4737        }
4738    }
4739}
4740
4741/// Serve the JSON artifact for a run — view or download.
4742fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4743    match fs::read(path) {
4744        Ok(bytes) => {
4745            if wants_download {
4746                (
4747                    [
4748                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
4749                        (
4750                            header::CONTENT_DISPOSITION,
4751                            "attachment; filename=result.json",
4752                        ),
4753                    ],
4754                    bytes,
4755                )
4756                    .into_response()
4757            } else {
4758                (
4759                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
4760                    bytes,
4761                )
4762                    .into_response()
4763            }
4764        }
4765        Err(err) => {
4766            let filename = path.file_name().map_or_else(
4767                || "result.json".to_string(),
4768                |n| n.to_string_lossy().into_owned(),
4769            );
4770            let msg = format!(
4771                "JSON result '{filename}' could not be read.\n\n\
4772                 Error: {err}\n\n\
4773                 If you moved or renamed the output folder, the stored path is now stale. \
4774                 Use 'Open JSON folder' from the results page to browse the output directory."
4775            );
4776            let html = ErrorTemplate {
4777                message: msg,
4778                last_report_url: Some("/view-reports".to_string()),
4779                last_report_label: Some("View Reports".to_string()),
4780                csp_nonce: csp_nonce.to_owned(),
4781                version: env!("CARGO_PKG_VERSION"),
4782            }
4783            .render()
4784            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4785            (StatusCode::NOT_FOUND, Html(html)).into_response()
4786        }
4787    }
4788}
4789
4790/// Recover a `RunArtifacts` from the persisted registry for a run ID.
4791fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
4792    let output_dir = entry
4793        .html_path
4794        .as_ref()
4795        .or(entry.json_path.as_ref())
4796        .or(entry.pdf_path.as_ref())
4797        .or(entry.csv_path.as_ref())
4798        .or(entry.xlsx_path.as_ref())
4799        .and_then(|p| p.parent().map(PathBuf::from))
4800        .unwrap_or_default();
4801    // Recover pdf_path: use the persisted one, or look for report.pdf
4802    // adjacent to html/json if only the old entries lack it.
4803    let pdf_path = entry.pdf_path.clone().or_else(|| {
4804        let candidate = output_dir.join("report.pdf");
4805        candidate.exists().then_some(candidate)
4806    });
4807    // csv_path / xlsx_path: persisted paths take precedence; fall back to
4808    // scanning the run directory for files matching the expected patterns so
4809    // that runs created before this feature still surface their artifacts.
4810    let csv_path = entry.csv_path.clone().or_else(|| {
4811        fs::read_dir(&output_dir).ok().and_then(|entries| {
4812            entries
4813                .filter_map(std::result::Result::ok)
4814                .find(|e| {
4815                    let n = e.file_name();
4816                    let n = n.to_string_lossy();
4817                    n.starts_with("report_") && n.ends_with(".csv")
4818                })
4819                .map(|e| e.path())
4820        })
4821    });
4822    let xlsx_path = entry.xlsx_path.clone().or_else(|| {
4823        fs::read_dir(&output_dir).ok().and_then(|entries| {
4824            entries
4825                .filter_map(std::result::Result::ok)
4826                .find(|e| {
4827                    let n = e.file_name();
4828                    let n = n.to_string_lossy();
4829                    n.starts_with("report_") && n.ends_with(".xlsx")
4830                })
4831                .map(|e| e.path())
4832        })
4833    });
4834    RunArtifacts {
4835        output_dir: output_dir.clone(),
4836        html_path: entry.html_path.clone(),
4837        pdf_path,
4838        json_path: entry.json_path.clone(),
4839        csv_path,
4840        xlsx_path,
4841        scan_config_path: find_scan_config_in_dir(&output_dir),
4842        report_title: entry.project_label.clone(),
4843        result_context: RunResultContext::default(),
4844    }
4845}
4846
4847#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
4848async fn resolve_artifact_set(
4849    state: &AppState,
4850    run_id: &str,
4851    csp_nonce: &str,
4852) -> Result<RunArtifacts, Response> {
4853    let cached = state.artifacts.lock().await.get(run_id).cloned();
4854    if let Some(a) = cached {
4855        return Ok(a);
4856    }
4857    let reg = state.registry.lock().await;
4858    if let Some(entry) = reg.find_by_run_id(run_id) {
4859        return Ok(recover_artifacts_from_registry(entry));
4860    }
4861    drop(reg);
4862    let short_id = &run_id[..run_id.len().min(8)];
4863    let hint = if matches!(
4864        run_id,
4865        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
4866    ) {
4867        format!(
4868            " The URL format appears to be reversed — \
4869             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
4870             Use the View Reports page to navigate to your scan."
4871        )
4872    } else {
4873        " The report may have been deleted or the report directory moved. \
4874         Use View Reports to browse your scan history."
4875            .to_string()
4876    };
4877    let error_html = ErrorTemplate {
4878        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
4879        last_report_url: Some("/view-reports".to_string()),
4880        last_report_label: Some("View Reports".to_string()),
4881        csp_nonce: csp_nonce.to_owned(),
4882        version: env!("CARGO_PKG_VERSION"),
4883    }
4884    .render()
4885    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4886    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
4887}
4888
4889#[allow(clippy::too_many_lines)] // bulk is an inline HTML string for the PDF-waiting page
4890async fn artifact_handler(
4891    State(state): State<AppState>,
4892    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4893    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
4894    Query(query): Query<ArtifactQuery>,
4895) -> Response {
4896    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
4897        Ok(a) => a,
4898        Err(r) => return r,
4899    };
4900
4901    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
4902
4903    match artifact.as_str() {
4904        "html" => {
4905            let Some(path) = artifact_set.html_path else {
4906                return StatusCode::NOT_FOUND.into_response();
4907            };
4908            serve_html_artifact(&path, wants_download, &csp_nonce)
4909        }
4910        "pdf" => {
4911            let Some(path) = artifact_set.pdf_path else {
4912                let msg = "PDF report was not generated for this run, or was not recorded in \
4913                           the scan registry. Re-run the analysis with PDF output enabled."
4914                    .to_string();
4915                let html = ErrorTemplate {
4916                    message: msg,
4917                    last_report_url: Some(format!("/runs/html/{run_id}")),
4918                    last_report_label: Some("View HTML Report".to_string()),
4919                    csp_nonce: csp_nonce.clone(),
4920                    version: env!("CARGO_PKG_VERSION"),
4921                }
4922                .render()
4923                .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
4924                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4925            };
4926            // PDF path is recorded but the background task may still be writing it.
4927            // Return a self-refreshing "please wait" page rather than an error.
4928            if !path.exists() {
4929                let html = format!(
4930                    "<!doctype html><html lang=\"en\"><head>\
4931                     <meta charset=utf-8>\
4932                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
4933                     <meta http-equiv=\"refresh\" content=\"5\">\
4934                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
4935                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
4936                     <style nonce=\"{csp_nonce}\">\
4937                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
4938                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
4939                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
4940                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
4941                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
4942                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
4943                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
4944                     background:var(--bg);color:var(--text);}}\
4945                     .top-nav{{position:sticky;top:0;z-index:30;\
4946                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
4947                     border-bottom:1px solid rgba(255,255,255,0.12);\
4948                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
4949                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
4950                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
4951                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
4952                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
4953                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
4954                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
4955                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
4956                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
4957                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
4958                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
4959                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
4960                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
4961                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
4962                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
4963                     justify-content:center;min-height:38px;border-radius:999px;\
4964                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
4965                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
4966                     .theme-toggle .icon-sun{{display:none;}}\
4967                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
4968                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
4969                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
4970                     display:flex;align-items:center;justify-content:center;\
4971                     min-height:calc(100vh - 56px);}}\
4972                     .panel{{background:var(--surface);border:1px solid var(--line);\
4973                     border-radius:var(--radius);box-shadow:var(--shadow);\
4974                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
4975                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
4976                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
4977                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
4978                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
4979                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
4980                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
4981                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
4982                     min-height:42px;padding:0 20px;border-radius:14px;\
4983                     border:1px solid var(--line-strong);text-decoration:none;\
4984                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
4985                     .back-link:hover{{background:var(--line);}}\
4986                     </style></head>\
4987                     <body>\
4988                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
4989                       <a class=\"brand\" href=\"/\">\
4990                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
4991                         <div class=\"brand-copy\">\
4992                           <div class=\"brand-title\">OxideSLOC</div>\
4993                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
4994                         </div>\
4995                       </a>\
4996                       <div class=\"nav-right\">\
4997                         <a class=\"nav-pill\" href=\"/\">Home</a>\
4998                         <div class=\"nav-dropdown\">\
4999                           <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>\
5000                           <div class=\"nav-dropdown-menu\">\
5001                             <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>\
5002                           </div>\
5003                         </div>\
5004                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5005                           <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>\
5006                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5007                           <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>\
5008                         </button>\
5009                       </div>\
5010                     </div></div>\
5011                     <div class=\"page\"><div class=\"panel\">\
5012                       <div class=\"spin-ring\"></div>\
5013                       <h1>Generating PDF\u{2026}</h1>\
5014                       <p>The PDF is being rendered from the HTML report.<br>\
5015                       This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
5016                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5017                     </div></div>\
5018                     <script nonce=\"{csp_nonce}\">\
5019                     (function(){{\
5020                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5021                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
5022                       var t=document.getElementById(\"theme-toggle\");\
5023                       if(t)t.addEventListener(\"click\",function(){{\
5024                         var d=b.classList.toggle(\"dark-theme\");\
5025                         localStorage.setItem(k,d?\"dark\":\"light\");\
5026                       }});\
5027                     }})();\
5028                     </script>\
5029                     </body></html>"
5030                );
5031                return Html(html).into_response();
5032            }
5033            serve_pdf_artifact(
5034                &path,
5035                &artifact_set.report_title,
5036                &run_id,
5037                wants_download,
5038                &csp_nonce,
5039            )
5040        }
5041        "json" => {
5042            let Some(path) = artifact_set.json_path else {
5043                let msg = "JSON result was not generated for this run, or was not recorded in \
5044                           the scan registry. Re-run the analysis with JSON output enabled."
5045                    .to_string();
5046                let html = ErrorTemplate {
5047                    message: msg,
5048                    last_report_url: Some("/view-reports".to_string()),
5049                    last_report_label: Some("View Reports".to_string()),
5050                    csp_nonce: csp_nonce.clone(),
5051                    version: env!("CARGO_PKG_VERSION"),
5052                }
5053                .render()
5054                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
5055                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5056            };
5057            serve_json_artifact(&path, wants_download, &csp_nonce)
5058        }
5059        "csv" => {
5060            let Some(path) = artifact_set.csv_path else {
5061                let msg = "CSV report was not generated for this run, or was not recorded in \
5062                           the scan registry."
5063                    .to_string();
5064                let html = ErrorTemplate {
5065                    message: msg,
5066                    last_report_url: Some(format!("/runs/html/{run_id}")),
5067                    last_report_label: Some("View HTML Report".to_string()),
5068                    csp_nonce: csp_nonce.clone(),
5069                    version: env!("CARGO_PKG_VERSION"),
5070                }
5071                .render()
5072                .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
5073                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5074            };
5075            fs::read(&path).map_or_else(
5076                |_| StatusCode::NOT_FOUND.into_response(),
5077                |bytes| {
5078                    let filename = path.file_name().map_or_else(
5079                        || "report.csv".to_string(),
5080                        |n| n.to_string_lossy().into_owned(),
5081                    );
5082                    (
5083                        [
5084                            (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
5085                            (
5086                                header::CONTENT_DISPOSITION,
5087                                format!("attachment; filename=\"{filename}\""),
5088                            ),
5089                        ],
5090                        bytes,
5091                    )
5092                        .into_response()
5093                },
5094            )
5095        }
5096        "xlsx" => {
5097            let Some(path) = artifact_set.xlsx_path else {
5098                let msg = "Excel report was not generated for this run, or was not recorded in \
5099                           the scan registry."
5100                    .to_string();
5101                let html = ErrorTemplate {
5102                    message: msg,
5103                    last_report_url: Some(format!("/runs/html/{run_id}")),
5104                    last_report_label: Some("View HTML Report".to_string()),
5105                    csp_nonce: csp_nonce.clone(),
5106                    version: env!("CARGO_PKG_VERSION"),
5107                }
5108                .render()
5109                .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
5110                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5111            };
5112            fs::read(&path).map_or_else(
5113                |_| StatusCode::NOT_FOUND.into_response(),
5114                |bytes| {
5115                    let filename = path.file_name().map_or_else(
5116                        || "report.xlsx".to_string(),
5117                        |n| n.to_string_lossy().into_owned(),
5118                    );
5119                    (
5120                        [
5121                            (
5122                                header::CONTENT_TYPE,
5123                                "application/vnd.openxmlformats-officedocument\
5124                                 .spreadsheetml.sheet"
5125                                    .to_string(),
5126                            ),
5127                            (
5128                                header::CONTENT_DISPOSITION,
5129                                format!("attachment; filename=\"{filename}\""),
5130                            ),
5131                        ],
5132                        bytes,
5133                    )
5134                        .into_response()
5135                },
5136            )
5137        }
5138        "scan-config" => {
5139            let path = artifact_set
5140                .scan_config_path
5141                .as_deref()
5142                .map(std::path::Path::to_path_buf)
5143                .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
5144                .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
5145            fs::read(&path).map_or_else(
5146                |_| StatusCode::NOT_FOUND.into_response(),
5147                |bytes| {
5148                    (
5149                        [
5150                            (
5151                                header::CONTENT_TYPE,
5152                                "application/json; charset=utf-8".to_string(),
5153                            ),
5154                            (
5155                                header::CONTENT_DISPOSITION,
5156                                "attachment; filename=\"scan-config.json\"".to_string(),
5157                            ),
5158                        ],
5159                        bytes,
5160                    )
5161                        .into_response()
5162                },
5163            )
5164        }
5165        _ if artifact.starts_with("sub_") => {
5166            if artifact.len() > 128
5167                || !artifact
5168                    .chars()
5169                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
5170            {
5171                return StatusCode::BAD_REQUEST.into_response();
5172            }
5173            let filename = format!("{artifact}.html");
5174            let path = artifact_set.output_dir.join(&filename);
5175            if !path.exists() {
5176                let html = ErrorTemplate {
5177                    message: format!(
5178                        "Sub-report '{artifact}' was not found in the run directory.\n\
5179                         Re-run the analysis with 'Detect and separate git submodules' \
5180                         and HTML output enabled."
5181                    ),
5182                    last_report_url: Some("/view-reports".to_string()),
5183                    last_report_label: Some("View Reports".to_string()),
5184                    csp_nonce: csp_nonce.clone(),
5185                    version: env!("CARGO_PKG_VERSION"),
5186                }
5187                .render()
5188                .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
5189                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5190            }
5191            serve_html_artifact(&path, wants_download, &csp_nonce)
5192        }
5193        _ => StatusCode::NOT_FOUND.into_response(),
5194    }
5195}
5196
5197// ── History ───────────────────────────────────────────────────────────────────
5198
5199struct SubmoduleLinkRow {
5200    name: String,
5201    url: String,
5202}
5203
5204struct HistoryEntryRow {
5205    run_id: String,
5206    run_id_short: String,
5207    timestamp: String,
5208    timestamp_utc_ms: i64,
5209    project_label: String,
5210    project_path: String,
5211    files_analyzed: u64,
5212    files_skipped: u64,
5213    code_lines: u64,
5214    comment_lines: u64,
5215    blank_lines: u64,
5216    git_branch: String,
5217    git_commit: String,
5218    has_html: bool,
5219    has_json: bool,
5220    has_pdf: bool,
5221    submodule_links: Vec<SubmoduleLinkRow>,
5222    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
5223    submodule_names_csv: String,
5224}
5225
5226/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
5227fn nth_weekday_of_month(
5228    year: i32,
5229    month: u32,
5230    weekday: chrono::Weekday,
5231    n: u32,
5232) -> chrono::NaiveDate {
5233    use chrono::Datelike;
5234    let mut count = 0u32;
5235    let mut day = 1u32;
5236    loop {
5237        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
5238        if d.weekday() == weekday {
5239            count += 1;
5240            if count == n {
5241                return d;
5242            }
5243        }
5244        day += 1;
5245    }
5246}
5247
5248/// Returns true if `dt` falls within US Pacific Daylight Time.
5249/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
5250/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
5251fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
5252    use chrono::{Datelike, TimeZone};
5253    let year = dt.year();
5254    let dst_start = chrono::Utc.from_utc_datetime(
5255        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
5256            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
5257    );
5258    let dst_end = chrono::Utc.from_utc_datetime(
5259        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
5260            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
5261    );
5262    dt >= dst_start && dt < dst_end
5263}
5264
5265fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
5266    if is_pacific_dst(dt) {
5267        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
5268            .format("%Y-%m-%d %H:%M PDT")
5269            .to_string()
5270    } else {
5271        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
5272            .format("%Y-%m-%d %H:%M PST")
5273            .to_string()
5274    }
5275}
5276
5277fn fmt_git_date(iso: &str) -> Option<String> {
5278    chrono::DateTime::parse_from_rfc3339(iso)
5279        .ok()
5280        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
5281}
5282
5283fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
5284    reg.entries
5285        .iter()
5286        .map(|e| {
5287            let submodule_links = {
5288                let mut links: Vec<SubmoduleLinkRow> = vec![];
5289                let sub_dir = e
5290                    .html_path
5291                    .as_ref()
5292                    .and_then(|p| p.parent())
5293                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5294                if let Some(dir) = sub_dir {
5295                    if let Ok(rd) = std::fs::read_dir(dir) {
5296                        for entry_res in rd.flatten() {
5297                            let fname = entry_res.file_name();
5298                            let fname_str = fname.to_string_lossy();
5299                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5300                                let stem = &fname_str[..fname_str.len() - 5];
5301                                let display = stem[4..].replace('-', " ");
5302                                links.push(SubmoduleLinkRow {
5303                                    name: display,
5304                                    url: format!("/runs/{stem}/{}", e.run_id),
5305                                });
5306                            }
5307                        }
5308                    }
5309                }
5310                links.sort_by(|a, b| a.name.cmp(&b.name));
5311                links
5312            };
5313            let submodule_names_csv = submodule_links
5314                .iter()
5315                .map(|l| l.name.as_str())
5316                .collect::<Vec<_>>()
5317                .join(",");
5318            HistoryEntryRow {
5319                run_id: e.run_id.clone(),
5320                run_id_short: e
5321                    .run_id
5322                    .split('-')
5323                    .next_back()
5324                    .unwrap_or(&e.run_id)
5325                    .chars()
5326                    .take(7)
5327                    .collect(),
5328                timestamp: fmt_la_time(e.timestamp_utc),
5329                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
5330                project_label: e.project_label.clone(),
5331                project_path: e
5332                    .input_roots
5333                    .first()
5334                    .map(|s| sanitize_path_str(s))
5335                    .unwrap_or_default(),
5336                files_analyzed: e.summary.files_analyzed,
5337                files_skipped: e.summary.files_skipped,
5338                code_lines: e.summary.code_lines,
5339                comment_lines: e.summary.comment_lines,
5340                blank_lines: e.summary.blank_lines,
5341                git_branch: e.git_branch.clone().unwrap_or_default(),
5342                git_commit: e.git_commit.clone().unwrap_or_default(),
5343                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
5344                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
5345                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
5346                submodule_links,
5347                submodule_names_csv,
5348            }
5349        })
5350        .collect()
5351}
5352
5353#[derive(Deserialize, Default)]
5354struct HistoryQuery {
5355    linked: Option<String>,
5356    error: Option<String>,
5357}
5358
5359async fn history_handler(
5360    State(state): State<AppState>,
5361    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5362    Query(query): Query<HistoryQuery>,
5363) -> impl IntoResponse {
5364    // Auto-scan all watched directories before rendering so the list stays fresh.
5365    auto_scan_watched_dirs(&state).await;
5366    let watched_dirs: Vec<String> = {
5367        let wd = state.watched_dirs.lock().await;
5368        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5369    };
5370    let mut entries = {
5371        let reg = state.registry.lock().await;
5372        make_history_rows(&reg)
5373    };
5374    entries.retain(|e| e.has_html);
5375    let total_scans = entries.len();
5376    let linked_count = query
5377        .linked
5378        .as_deref()
5379        .and_then(|s| s.parse::<usize>().ok())
5380        .unwrap_or(0);
5381    let browse_error = query.error.filter(|s| !s.is_empty());
5382    let template = HistoryTemplate {
5383        version: env!("CARGO_PKG_VERSION"),
5384        entries,
5385        total_scans,
5386        linked_count,
5387        browse_error,
5388        watched_dirs,
5389        csp_nonce,
5390        server_mode: state.server_mode,
5391    };
5392    Html(
5393        template
5394            .render()
5395            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5396    )
5397    .into_response()
5398}
5399
5400async fn compare_select_handler(
5401    State(state): State<AppState>,
5402    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5403) -> impl IntoResponse {
5404    auto_scan_watched_dirs(&state).await;
5405    let watched_dirs: Vec<String> = {
5406        let wd = state.watched_dirs.lock().await;
5407        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5408    };
5409    let mut entries = {
5410        let reg = state.registry.lock().await;
5411        make_history_rows(&reg)
5412    };
5413    entries.retain(|e| e.has_json);
5414    let total_scans = entries.len();
5415    let template = CompareSelectTemplate {
5416        version: env!("CARGO_PKG_VERSION"),
5417        entries,
5418        total_scans,
5419        watched_dirs,
5420        csp_nonce,
5421        server_mode: state.server_mode,
5422    };
5423    Html(
5424        template
5425            .render()
5426            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5427    )
5428    .into_response()
5429}
5430
5431// ── Compare ───────────────────────────────────────────────────────────────────
5432
5433#[derive(Deserialize, Default)]
5434struct CompareQuery {
5435    a: Option<String>,
5436    b: Option<String>,
5437    /// Optional submodule name to scope the comparison to one submodule.
5438    sub: Option<String>,
5439    /// "super" to exclude all submodule files and show only the super-repo.
5440    scope: Option<String>,
5441}
5442
5443struct CompareFileDeltaRow {
5444    relative_path: String,
5445    language: String,
5446    status: String,
5447    baseline_code: i64,
5448    current_code: i64,
5449    code_delta_str: String,
5450    code_delta_class: String,
5451    comment_delta_str: String,
5452    comment_delta_class: String,
5453    total_delta_str: String,
5454    total_delta_class: String,
5455}
5456
5457/// Recompute `summary_totals` from the current `per_file_records` slice.
5458/// Used when `per_file_records` has been narrowed to a submodule subset.
5459fn recompute_summary_from_records(run: &mut AnalysisRun) {
5460    let files_analyzed = run
5461        .per_file_records
5462        .iter()
5463        .filter(|r| r.language.is_some())
5464        .count() as u64;
5465    let code_lines: u64 = run
5466        .per_file_records
5467        .iter()
5468        .map(|r| r.effective_counts.code_lines)
5469        .sum();
5470    let comment_lines: u64 = run
5471        .per_file_records
5472        .iter()
5473        .map(|r| r.effective_counts.comment_lines)
5474        .sum();
5475    let blank_lines: u64 = run
5476        .per_file_records
5477        .iter()
5478        .map(|r| r.effective_counts.blank_lines)
5479        .sum();
5480    run.summary_totals.files_analyzed = files_analyzed;
5481    run.summary_totals.files_considered = files_analyzed;
5482    run.summary_totals.code_lines = code_lines;
5483    run.summary_totals.comment_lines = comment_lines;
5484    run.summary_totals.blank_lines = blank_lines;
5485    run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
5486}
5487
5488fn fmt_delta(n: i64) -> String {
5489    if n > 0 {
5490        format!("+{n}")
5491    } else {
5492        format!("{n}")
5493    }
5494}
5495
5496fn delta_class(n: i64) -> &'static str {
5497    use std::cmp::Ordering;
5498    match n.cmp(&0) {
5499        Ordering::Greater => "pos",
5500        Ordering::Less => "neg",
5501        Ordering::Equal => "zero",
5502    }
5503}
5504
5505// ratio/percentage display, precision loss acceptable
5506#[allow(clippy::cast_precision_loss)]
5507fn fmt_pct(delta: i64, baseline: u64) -> String {
5508    if baseline == 0 {
5509        return "—".to_string();
5510    }
5511    #[allow(clippy::cast_precision_loss)]
5512    let pct = (delta as f64 / baseline as f64) * 100.0;
5513    if pct > 0.049 {
5514        format!("+{pct:.1}%")
5515    } else if pct < -0.049 {
5516        format!("{pct:.1}%")
5517    } else {
5518        "±0%".to_string()
5519    }
5520}
5521
5522/// Returns (`display_string`, `css_class`) for a numeric change column cell.
5523fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
5524    prev.map_or_else(
5525        || ("—".to_string(), "na"),
5526        |p| {
5527            #[allow(clippy::cast_possible_wrap)]
5528            let d = curr as i64 - p as i64;
5529            (fmt_delta(d), delta_class(d))
5530        },
5531    )
5532}
5533
5534#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
5535fn load_scan_for_compare(
5536    json_path: &std::path::Path,
5537    scan_label: &str,
5538    run_id: &str,
5539    server_mode: bool,
5540    compare_url: &str,
5541    csp_nonce: &str,
5542) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
5543    match read_json(json_path) {
5544        Ok(r) => Ok(r),
5545        Err(e) => {
5546            if server_mode {
5547                let html = ErrorTemplate {
5548                    message: format!(
5549                        "Could not load {scan_label} scan data. The scan output folder may have \
5550                         been moved, renamed, or deleted. Re-running the analysis will create \
5551                         fresh comparison data."
5552                    ),
5553                    last_report_url: Some("/compare-scans".to_string()),
5554                    last_report_label: Some("Compare Scans".to_string()),
5555                    csp_nonce: csp_nonce.to_owned(),
5556                    version: env!("CARGO_PKG_VERSION"),
5557                }
5558                .render()
5559                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
5560                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5561            }
5562            let msg = format!(
5563                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
5564                json_path.display()
5565            );
5566            let folder_hint = json_path
5567                .parent()
5568                .map(|p| p.display().to_string())
5569                .unwrap_or_default();
5570            Err(missing_scan_relocate_response(
5571                &msg,
5572                run_id,
5573                &folder_hint,
5574                compare_url,
5575                false,
5576                csp_nonce,
5577            ))
5578        }
5579    }
5580}
5581
5582struct ChurnStats {
5583    new_scope: bool,
5584    scope_flag: bool,
5585    churn_rate_str: String,
5586    churn_rate_class: String,
5587}
5588
5589fn compute_churn_stats(
5590    baseline_code: u64,
5591    current_code: u64,
5592    lines_added: i64,
5593    lines_removed: i64,
5594) -> ChurnStats {
5595    let new_scope = baseline_code == 0 && current_code > 0;
5596    #[allow(clippy::cast_precision_loss)]
5597    let churn_pct = if baseline_code > 0 {
5598        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
5599    } else {
5600        0.0
5601    };
5602    #[allow(clippy::cast_precision_loss)]
5603    let scope_flag =
5604        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
5605    let churn_rate_str = if new_scope {
5606        "New".to_string()
5607    } else if baseline_code > 0 {
5608        format!("{churn_pct:.1}%")
5609    } else {
5610        "—".to_string()
5611    };
5612    let churn_rate_class = if new_scope || churn_pct > 20.0 {
5613        "high".to_string()
5614    } else if churn_pct > 5.0 {
5615        "med".to_string()
5616    } else {
5617        "low".to_string()
5618    };
5619    ChurnStats {
5620        new_scope,
5621        scope_flag,
5622        churn_rate_str,
5623        churn_rate_class,
5624    }
5625}
5626
5627/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
5628/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
5629/// variables to the large CompareTemplate, which causes rustc stack overflows on Windows.
5630fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
5631    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
5632    if !has_data {
5633        return String::new();
5634    }
5635    let base_str = s
5636        .baseline_coverage_line_pct
5637        .map(|p| format!("{p:.1}%"))
5638        .unwrap_or_else(|| "\u{2014}".into());
5639    let curr_str = s
5640        .current_coverage_line_pct
5641        .map(|p| format!("{p:.1}%"))
5642        .unwrap_or_else(|| "\u{2014}".into());
5643    let (delta_str, cls) = match s.coverage_line_pct_delta {
5644        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
5645        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
5646        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
5647        None => ("\u{2014}".into(), "zero"),
5648    };
5649    format!(
5650        r#"<div class="delta-card">
5651          <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>
5652          <div class="delta-card-label">Line coverage</div>
5653          <div class="delta-card-from">Before: {base_str}</div>
5654          <div class="delta-card-to">{curr_str}</div>
5655          <span class="delta-card-change {cls}">{delta_str}</span>
5656        </div>"#
5657    )
5658}
5659
5660#[allow(clippy::too_many_lines)]
5661async fn compare_handler(
5662    State(state): State<AppState>,
5663    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5664    Query(query): Query<CompareQuery>,
5665) -> impl IntoResponse {
5666    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
5667    // redirect to the history page where the user can select two runs.
5668    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
5669        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
5670        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
5671    };
5672
5673    let (maybe_a, maybe_b) = {
5674        let reg = state.registry.lock().await;
5675        (
5676            reg.find_by_run_id(&run_id_a).cloned(),
5677            reg.find_by_run_id(&run_id_b).cloned(),
5678        )
5679    };
5680
5681    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
5682        let html = ErrorTemplate {
5683            message: "One or both run IDs were not found in scan history. \
5684                      The runs may have been deleted or the registry may have been reset."
5685                .to_string(),
5686            last_report_url: Some("/compare-scans".to_string()),
5687            last_report_label: Some("Compare Scans".to_string()),
5688            csp_nonce: csp_nonce.clone(),
5689            version: env!("CARGO_PKG_VERSION"),
5690        }
5691        .render()
5692        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
5693        return Html(html).into_response();
5694    };
5695
5696    // Ensure older scan is always the baseline.
5697    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
5698        (entry_a, entry_b)
5699    } else {
5700        (entry_b, entry_a)
5701    };
5702
5703    // If query params were in the wrong order, redirect to canonical URL so the
5704    // browser always shows the same URL for the same two scans regardless of how
5705    // the user arrived here (Full diff button vs. Compare Scans selection).
5706    if baseline_entry.run_id != run_id_a {
5707        let canonical = format!(
5708            "/compare?a={}&b={}",
5709            baseline_entry.run_id, current_entry.run_id
5710        );
5711        return axum::response::Redirect::to(&canonical).into_response();
5712    }
5713
5714    let (Some(base_json), Some(curr_json)) = (
5715        baseline_entry.json_path.as_ref(),
5716        current_entry.json_path.as_ref(),
5717    ) else {
5718        let html = ErrorTemplate {
5719            message: "Full comparison requires JSON scan data, which was not saved for one or \
5720                      both of these runs. JSON is now always saved for new scans — re-run the \
5721                      affected projects to enable comparisons."
5722                .to_string(),
5723            last_report_url: Some("/compare-scans".to_string()),
5724            last_report_label: Some("Compare Scans".to_string()),
5725            csp_nonce: csp_nonce.clone(),
5726            version: env!("CARGO_PKG_VERSION"),
5727        }
5728        .render()
5729        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
5730        return Html(html).into_response();
5731    };
5732
5733    let compare_url = format!(
5734        "/compare?a={}&b={}",
5735        baseline_entry.run_id, current_entry.run_id
5736    );
5737
5738    let baseline_run = match load_scan_for_compare(
5739        base_json,
5740        "baseline",
5741        &baseline_entry.run_id,
5742        state.server_mode,
5743        &compare_url,
5744        &csp_nonce,
5745    ) {
5746        Ok(r) => r,
5747        Err(resp) => return resp,
5748    };
5749    let current_run = match load_scan_for_compare(
5750        curr_json,
5751        "current",
5752        &current_entry.run_id,
5753        state.server_mode,
5754        &compare_url,
5755        &csp_nonce,
5756    ) {
5757        Ok(r) => r,
5758        Err(resp) => return resp,
5759    };
5760
5761    let active_submodule = query.sub.clone();
5762    let super_scope_active = query.scope.as_deref() == Some("super");
5763
5764    let submodule_options = baseline_run
5765        .submodule_summaries
5766        .iter()
5767        .chain(current_run.submodule_summaries.iter())
5768        .map(|s| s.name.clone())
5769        .collect::<std::collections::BTreeSet<_>>()
5770        .into_iter()
5771        .collect::<Vec<_>>();
5772    let has_any_submodule_data = !submodule_options.is_empty();
5773
5774    // Narrow per_file_records when a scope is active, then recompute totals.
5775    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
5776        let mut b = baseline_run;
5777        let mut c = current_run;
5778        b.per_file_records
5779            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
5780        c.per_file_records
5781            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
5782        recompute_summary_from_records(&mut b);
5783        recompute_summary_from_records(&mut c);
5784        (b, c)
5785    } else if super_scope_active {
5786        let mut b = baseline_run;
5787        let mut c = current_run;
5788        b.per_file_records.retain(|f| f.submodule.is_none());
5789        c.per_file_records.retain(|f| f.submodule.is_none());
5790        recompute_summary_from_records(&mut b);
5791        recompute_summary_from_records(&mut c);
5792        (b, c)
5793    } else {
5794        (baseline_run, current_run)
5795    };
5796
5797    let comparison = compute_delta(&effective_baseline, &effective_current);
5798
5799    let file_rows: Vec<CompareFileDeltaRow> = comparison
5800        .file_deltas
5801        .iter()
5802        .map(|d| CompareFileDeltaRow {
5803            relative_path: d.relative_path.clone(),
5804            language: d.language.clone().unwrap_or_else(|| "—".into()),
5805            status: match d.status {
5806                FileChangeStatus::Added => "added".into(),
5807                FileChangeStatus::Removed => "removed".into(),
5808                FileChangeStatus::Modified => "modified".into(),
5809                FileChangeStatus::Unchanged => "unchanged".into(),
5810            },
5811            baseline_code: d.baseline_code,
5812            current_code: d.current_code,
5813            code_delta_str: fmt_delta(d.code_delta),
5814            code_delta_class: delta_class(d.code_delta).into(),
5815            comment_delta_str: fmt_delta(d.comment_delta),
5816            comment_delta_class: delta_class(d.comment_delta).into(),
5817            total_delta_str: fmt_delta(d.total_delta),
5818            total_delta_class: delta_class(d.total_delta).into(),
5819        })
5820        .collect();
5821
5822    let project_path = baseline_entry
5823        .input_roots
5824        .first()
5825        .map(|s| sanitize_path_str(s))
5826        .unwrap_or_default();
5827    let lines_added = sum_added_code_lines(&comparison);
5828    let lines_removed = sum_removed_code_lines(&comparison);
5829    let churn = compute_churn_stats(
5830        comparison.summary.baseline_code,
5831        comparison.summary.current_code,
5832        lines_added,
5833        lines_removed,
5834    );
5835    let s = &comparison.summary;
5836    let template = CompareTemplate {
5837        version: env!("CARGO_PKG_VERSION"),
5838        project_label: baseline_entry.project_label.clone(),
5839        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
5840        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
5841        baseline_run_id: baseline_entry.run_id.clone(),
5842        current_run_id: current_entry.run_id.clone(),
5843        baseline_run_id_short: baseline_entry
5844            .run_id
5845            .split('-')
5846            .next_back()
5847            .unwrap_or(&baseline_entry.run_id)
5848            .chars()
5849            .take(7)
5850            .collect(),
5851        current_run_id_short: current_entry
5852            .run_id
5853            .split('-')
5854            .next_back()
5855            .unwrap_or(&current_entry.run_id)
5856            .chars()
5857            .take(7)
5858            .collect(),
5859        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
5860        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
5861        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
5862        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
5863        project_path: project_path.clone(),
5864        baseline_code: s.baseline_code,
5865        current_code: s.current_code,
5866        code_lines_delta_str: fmt_delta(s.code_lines_delta),
5867        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
5868        baseline_files: s.baseline_files,
5869        current_files: s.current_files,
5870        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
5871        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
5872        baseline_comments: s.baseline_comments,
5873        current_comments: s.current_comments,
5874        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
5875        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
5876        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
5877        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
5878        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
5879        code_lines_added: lines_added,
5880        code_lines_removed: lines_removed,
5881        new_scope: churn.new_scope,
5882        churn_rate_str: churn.churn_rate_str,
5883        churn_rate_class: churn.churn_rate_class,
5884        scope_flag: churn.scope_flag,
5885        files_added: comparison.files_added,
5886        files_removed: comparison.files_removed,
5887        files_modified: comparison.files_modified,
5888        files_unchanged: comparison.files_unchanged,
5889        file_rows,
5890        baseline_git_author: baseline_entry.git_author.clone(),
5891        current_git_author: current_entry.git_author.clone(),
5892        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
5893        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
5894        baseline_git_tags: baseline_entry.git_tags.clone(),
5895        current_git_tags: current_entry.git_tags.clone(),
5896        baseline_git_commit_date: baseline_entry
5897            .git_commit_date
5898            .as_deref()
5899            .and_then(fmt_git_date),
5900        current_git_commit_date: current_entry
5901            .git_commit_date
5902            .as_deref()
5903            .and_then(fmt_git_date),
5904        project_name: project_path
5905            .rsplit(['/', '\\'])
5906            .find(|s| !s.is_empty())
5907            .unwrap_or(&project_path)
5908            .to_string(),
5909        submodule_options,
5910        has_any_submodule_data,
5911        active_submodule,
5912        super_scope_active,
5913        csp_nonce,
5914        coverage_delta_card: build_coverage_delta_card(s),
5915    };
5916
5917    Html(
5918        template
5919            .render()
5920            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5921    )
5922    .into_response()
5923}
5924
5925// ── Badge endpoint ────────────────────────────────────────────────────────────
5926// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
5927// pages, Jira descriptions, etc.
5928//
5929// GET /badge/<metric>?label=<override>&color=<hex>
5930// Metrics: code-lines  files  comment-lines  blank-lines
5931
5932fn format_number(n: u64) -> String {
5933    let s = n.to_string();
5934    let mut out = String::with_capacity(s.len() + s.len() / 3);
5935    let len = s.len();
5936    for (i, c) in s.chars().enumerate() {
5937        if i > 0 && (len - i).is_multiple_of(3) {
5938            out.push(',');
5939        }
5940        out.push(c);
5941    }
5942    out
5943}
5944
5945const fn badge_char_width(c: char) -> f64 {
5946    match c {
5947        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
5948        'm' | 'w' => 9.0,
5949        ' ' => 4.0,
5950        _ => 6.5,
5951    }
5952}
5953
5954#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5955fn badge_text_px(text: &str) -> u32 {
5956    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
5957}
5958
5959fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
5960    let lw = badge_text_px(label) + 20;
5961    let rw = badge_text_px(value) + 20;
5962    let total = lw + rw;
5963    let lx = lw / 2;
5964    let rx = lw + rw / 2;
5965    let le = escape_html(label);
5966    let ve = escape_html(value);
5967    let ce = escape_html(color);
5968    format!(
5969        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
5970  <rect width="{total}" height="20" fill="#555"/>
5971  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
5972  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
5973    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
5974    <text x="{lx}" y="13">{le}</text>
5975    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
5976    <text x="{rx}" y="13">{ve}</text>
5977  </g>
5978</svg>"##
5979    )
5980}
5981
5982#[derive(Deserialize)]
5983struct BadgeQuery {
5984    label: Option<String>,
5985    color: Option<String>,
5986}
5987
5988async fn badge_handler(
5989    State(state): State<AppState>,
5990    AxumPath(metric): AxumPath<String>,
5991    Query(query): Query<BadgeQuery>,
5992) -> Response {
5993    let entry = {
5994        let reg = state.registry.lock().await;
5995        reg.entries.first().cloned()
5996    };
5997
5998    let Some(entry) = entry else {
5999        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
6000        return (
6001            [
6002                (header::CONTENT_TYPE, "image/svg+xml"),
6003                (header::CACHE_CONTROL, "no-cache, max-age=0"),
6004            ],
6005            svg,
6006        )
6007            .into_response();
6008    };
6009
6010    let (default_label, value, default_color) = match metric.as_str() {
6011        "code-lines" => (
6012            "code lines",
6013            format_number(entry.summary.code_lines),
6014            "#4a78ee",
6015        ),
6016        "files" => (
6017            "files analyzed",
6018            format_number(entry.summary.files_analyzed),
6019            "#4a9862",
6020        ),
6021        "comment-lines" => (
6022            "comment lines",
6023            format_number(entry.summary.comment_lines),
6024            "#b35428",
6025        ),
6026        "blank-lines" => (
6027            "blank lines",
6028            format_number(entry.summary.blank_lines),
6029            "#7a5db0",
6030        ),
6031        _ => return StatusCode::NOT_FOUND.into_response(),
6032    };
6033
6034    let label = query.label.as_deref().unwrap_or(default_label);
6035    let color = query.color.as_deref().unwrap_or(default_color);
6036    let svg = render_badge_svg(label, &value, color);
6037
6038    (
6039        [
6040            (header::CONTENT_TYPE, "image/svg+xml"),
6041            (header::CACHE_CONTROL, "no-cache, max-age=0"),
6042        ],
6043        svg,
6044    )
6045        .into_response()
6046}
6047
6048// ── Metrics API ───────────────────────────────────────────────────────────────
6049// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
6050// Confluence automation, Jira webhooks, etc.
6051//
6052// GET /api/metrics/latest
6053// GET /api/metrics/<run_id>
6054
6055#[derive(Serialize)]
6056struct ApiCoverageBlock {
6057    lines_found: u64,
6058    lines_hit: u64,
6059    line_pct: f64,
6060    functions_found: u64,
6061    functions_hit: u64,
6062    function_pct: f64,
6063    branches_found: u64,
6064    branches_hit: u64,
6065    branch_pct: f64,
6066}
6067
6068#[derive(Serialize)]
6069struct ApiMetricsResponse {
6070    run_id: String,
6071    timestamp: String,
6072    project: String,
6073    summary: ApiSummaryPayload,
6074    languages: Vec<ApiLanguageRow>,
6075    #[serde(skip_serializing_if = "Option::is_none")]
6076    coverage: Option<ApiCoverageBlock>,
6077}
6078
6079#[derive(Serialize)]
6080struct ApiSummaryPayload {
6081    files_analyzed: u64,
6082    files_skipped: u64,
6083    code_lines: u64,
6084    comment_lines: u64,
6085    blank_lines: u64,
6086    total_physical_lines: u64,
6087    functions: u64,
6088    classes: u64,
6089    variables: u64,
6090    imports: u64,
6091}
6092
6093#[derive(Serialize)]
6094struct ApiLanguageRow {
6095    name: String,
6096    files: u64,
6097    code_lines: u64,
6098    comment_lines: u64,
6099    blank_lines: u64,
6100    functions: u64,
6101    classes: u64,
6102    variables: u64,
6103    imports: u64,
6104}
6105
6106async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
6107    let entry = {
6108        let reg = state.registry.lock().await;
6109        reg.entries.first().cloned()
6110    };
6111    entry.map_or_else(
6112        || error::not_found("no scans recorded yet"),
6113        |e| build_metrics_response(&e),
6114    )
6115}
6116
6117async fn api_metrics_run_handler(
6118    State(state): State<AppState>,
6119    AxumPath(run_id): AxumPath<String>,
6120) -> Response {
6121    let entry = {
6122        let reg = state.registry.lock().await;
6123        reg.find_by_run_id(&run_id).cloned()
6124    };
6125    entry.map_or_else(
6126        || error::not_found("run not found"),
6127        |e| build_metrics_response(&e),
6128    )
6129}
6130
6131fn build_metrics_response(entry: &RegistryEntry) -> Response {
6132    let languages: Vec<ApiLanguageRow> = entry
6133        .json_path
6134        .as_ref()
6135        .and_then(|p| read_json(p).ok())
6136        .map(|run| {
6137            run.totals_by_language
6138                .iter()
6139                .map(|l| ApiLanguageRow {
6140                    name: l.language.display_name().to_string(),
6141                    files: l.files,
6142                    code_lines: l.code_lines,
6143                    comment_lines: l.comment_lines,
6144                    blank_lines: l.blank_lines,
6145                    functions: l.functions,
6146                    classes: l.classes,
6147                    variables: l.variables,
6148                    imports: l.imports,
6149                })
6150                .collect()
6151        })
6152        .unwrap_or_default();
6153
6154    let s = &entry.summary;
6155    let coverage = if s.coverage_lines_found > 0 {
6156        let pct = |hit: u64, found: u64| -> f64 {
6157            if found == 0 {
6158                0.0
6159            } else {
6160                #[allow(clippy::cast_precision_loss)]
6161                let v = (hit as f64 / found as f64) * 100.0;
6162                (v * 10.0).round() / 10.0
6163            }
6164        };
6165        Some(ApiCoverageBlock {
6166            lines_found: s.coverage_lines_found,
6167            lines_hit: s.coverage_lines_hit,
6168            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
6169            functions_found: s.coverage_functions_found,
6170            functions_hit: s.coverage_functions_hit,
6171            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
6172            branches_found: s.coverage_branches_found,
6173            branches_hit: s.coverage_branches_hit,
6174            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
6175        })
6176    } else {
6177        None
6178    };
6179    Json(ApiMetricsResponse {
6180        run_id: entry.run_id.clone(),
6181        timestamp: entry.timestamp_utc.to_rfc3339(),
6182        project: entry.project_label.clone(),
6183        summary: ApiSummaryPayload {
6184            files_analyzed: s.files_analyzed,
6185            files_skipped: s.files_skipped,
6186            code_lines: s.code_lines,
6187            comment_lines: s.comment_lines,
6188            blank_lines: s.blank_lines,
6189            total_physical_lines: s.total_physical_lines,
6190            functions: s.functions,
6191            classes: s.classes,
6192            variables: s.variables,
6193            imports: s.imports,
6194        },
6195        languages,
6196        coverage,
6197    })
6198    .into_response()
6199}
6200
6201// ── Project history API ───────────────────────────────────────────────────────
6202// Protected. Called by the wizard JS when the project path changes, so the UI
6203// can show a "scanned N times before" badge without a full page reload.
6204//
6205// GET /api/project-history?path=<project_root>
6206
6207#[derive(Deserialize)]
6208struct ProjectHistoryQuery {
6209    path: Option<String>,
6210}
6211
6212#[derive(Serialize)]
6213struct ProjectHistoryResponse {
6214    scan_count: usize,
6215    last_scan_id: Option<String>,
6216    last_scan_timestamp: Option<String>,
6217    last_scan_code_lines: Option<u64>,
6218    last_git_branch: Option<String>,
6219    last_git_commit: Option<String>,
6220}
6221
6222/// Return true if `entry` matches either an exact root path or an upload-staging
6223/// path with the same project name (needed because each upload gets a fresh UUID dir).
6224fn entry_matches_project(
6225    entry: &RegistryEntry,
6226    root_str: &str,
6227    upload_root: &str,
6228    upload_name_suffix: Option<&str>,
6229) -> bool {
6230    if entry.input_roots.iter().any(|r| r == root_str) {
6231        return true;
6232    }
6233    if let Some(suffix) = upload_name_suffix {
6234        return entry
6235            .input_roots
6236            .iter()
6237            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
6238    }
6239    false
6240}
6241
6242async fn project_history_handler(
6243    State(state): State<AppState>,
6244    Query(query): Query<ProjectHistoryQuery>,
6245) -> Response {
6246    let path = query.path.unwrap_or_default();
6247    let resolved = resolve_input_path(&path);
6248    let root_str = resolved.to_string_lossy().replace('\\', "/");
6249
6250    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
6251    // The UUID is freshly generated for every upload, so an exact root_str match never finds
6252    // previous scans of the same project. Fall back to matching by project name within the
6253    // uploads staging directory so Scan History populates correctly across uploads.
6254    let upload_root = std::env::temp_dir()
6255        .join("oxide-sloc-uploads")
6256        .to_string_lossy()
6257        .replace('\\', "/");
6258    let upload_name_suffix: Option<String> =
6259        if state.server_mode && root_str.starts_with(&upload_root) {
6260            resolved
6261                .file_name()
6262                .and_then(|n| n.to_str())
6263                .map(|name| format!("/{name}"))
6264        } else {
6265            None
6266        };
6267    let suffix_ref = upload_name_suffix.as_deref();
6268
6269    let entries: Vec<_> = {
6270        let reg = state.registry.lock().await;
6271        reg.entries
6272            .iter()
6273            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
6274            .cloned()
6275            .collect()
6276    };
6277    let scan_count = entries.len();
6278    let last = entries.first();
6279    let last_scan_id = last.map(|e| e.run_id.clone());
6280    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
6281    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
6282    let last_git_branch = last.and_then(|e| e.git_branch.clone());
6283    let last_git_commit = last.and_then(|e| e.git_commit.clone());
6284
6285    Json(ProjectHistoryResponse {
6286        scan_count,
6287        last_scan_id,
6288        last_scan_timestamp,
6289        last_scan_code_lines,
6290        last_git_branch,
6291        last_git_commit,
6292    })
6293    .into_response()
6294}
6295
6296// ── Metrics history API ───────────────────────────────────────────────────────
6297// Protected. Returns a JSON array of lightweight scan snapshots for plotting
6298// trend charts.
6299//
6300// GET /api/metrics/history?root=<path>&limit=<n>
6301
6302#[derive(Deserialize)]
6303struct MetricsHistoryQuery {
6304    root: Option<String>,
6305    limit: Option<usize>,
6306    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
6307    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
6308    submodule: Option<String>,
6309}
6310
6311#[derive(Serialize)]
6312struct MetricsSubmoduleLink {
6313    name: String,
6314    url: String,
6315}
6316
6317#[derive(Serialize)]
6318struct MetricsHistoryEntry {
6319    run_id: String,
6320    run_id_short: String,
6321    timestamp: String,
6322    commit: Option<String>,
6323    branch: Option<String>,
6324    tags: Vec<String>,
6325    nearest_tag: Option<String>,
6326    code_lines: u64,
6327    comment_lines: u64,
6328    blank_lines: u64,
6329    physical_lines: u64,
6330    files_analyzed: u64,
6331    files_skipped: u64,
6332    test_count: u64,
6333    project_label: String,
6334    html_url: Option<String>,
6335    has_pdf: bool,
6336    submodule_links: Vec<MetricsSubmoduleLink>,
6337    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
6338    #[serde(skip_serializing_if = "Option::is_none")]
6339    coverage_line_pct: Option<f64>,
6340}
6341
6342fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
6343    let mut links: Vec<MetricsSubmoduleLink> = vec![];
6344    let sub_dir = e
6345        .html_path
6346        .as_ref()
6347        .and_then(|p| p.parent())
6348        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6349    let Some(dir) = sub_dir else { return links };
6350    let Ok(rd) = std::fs::read_dir(dir) else {
6351        return links;
6352    };
6353    for entry_res in rd.flatten() {
6354        let fname = entry_res.file_name();
6355        let fname_str = fname.to_string_lossy();
6356        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6357            let stem = &fname_str[..fname_str.len() - 5];
6358            let display = stem[4..].replace('-', " ");
6359            links.push(MetricsSubmoduleLink {
6360                name: display,
6361                url: format!("/runs/{stem}/{}", e.run_id),
6362            });
6363        }
6364    }
6365    links.sort_by(|a, b| a.name.cmp(&b.name));
6366    links
6367}
6368
6369fn apply_submodule_filter(
6370    base: MetricsHistoryEntry,
6371    filter: &str,
6372    e: &sloc_core::history::RegistryEntry,
6373) -> Option<MetricsHistoryEntry> {
6374    let json_path = e.json_path.as_ref()?;
6375    let json_str = std::fs::read_to_string(json_path).ok()?;
6376    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
6377    let sub = run
6378        .submodule_summaries
6379        .iter()
6380        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
6381    let safe = sanitize_project_label(&sub.name);
6382    let artifact_key = format!("sub_{safe}");
6383    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
6384        || base.html_url.clone(),
6385        |run_dir| {
6386            let sub_path = run_dir.join(format!("{artifact_key}.html"));
6387            if sub_path.exists() {
6388                Some(format!("/runs/{artifact_key}/{}", e.run_id))
6389            } else {
6390                base.html_url.clone()
6391            }
6392        },
6393    );
6394    Some(MetricsHistoryEntry {
6395        code_lines: sub.code_lines,
6396        comment_lines: sub.comment_lines,
6397        blank_lines: sub.blank_lines,
6398        physical_lines: sub.total_physical_lines,
6399        files_analyzed: sub.files_analyzed,
6400        html_url: sub_html_url,
6401        has_pdf: false,
6402        submodule_links: vec![],
6403        ..base
6404    })
6405}
6406
6407#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
6408async fn api_metrics_history_handler(
6409    State(state): State<AppState>,
6410    Query(query): Query<MetricsHistoryQuery>,
6411) -> Response {
6412    let limit = query.limit.unwrap_or(50).min(500);
6413    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
6414
6415    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
6416        let reg = state.registry.lock().await;
6417        reg.entries
6418            .iter()
6419            .filter(|e| {
6420                query.root.as_ref().is_none_or(|root| {
6421                    let resolved = resolve_input_path(root);
6422                    let root_str = resolved.to_string_lossy().replace('\\', "/");
6423                    e.input_roots.iter().any(|r| r == &root_str)
6424                })
6425            })
6426            .take(limit)
6427            .cloned()
6428            .collect()
6429    };
6430
6431    let entries: Vec<MetricsHistoryEntry> = candidate_entries
6432        .into_iter()
6433        .filter_map(|e| {
6434            let tags = e
6435                .git_tags
6436                .as_deref()
6437                .map(|s| {
6438                    s.split(',')
6439                        .map(|t| t.trim().to_string())
6440                        .filter(|t| !t.is_empty())
6441                        .collect()
6442                })
6443                .unwrap_or_default();
6444            let html_url = e
6445                .html_path
6446                .as_ref()
6447                .filter(|p| p.exists())
6448                .map(|_| format!("/runs/html/{}", e.run_id));
6449            let nearest_tag = e.git_nearest_tag.clone();
6450            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
6451            let run_id_short: String = e
6452                .run_id
6453                .split('-')
6454                .next_back()
6455                .unwrap_or(&e.run_id)
6456                .chars()
6457                .take(7)
6458                .collect();
6459            let submodule_links = build_entry_submodule_links(&e);
6460            #[allow(clippy::cast_precision_loss)]
6461            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
6462                let pct = (e.summary.coverage_lines_hit as f64
6463                    / e.summary.coverage_lines_found as f64)
6464                    * 100.0;
6465                Some((pct * 10.0).round() / 10.0)
6466            } else {
6467                None
6468            };
6469            let base = MetricsHistoryEntry {
6470                run_id: e.run_id.clone(),
6471                run_id_short,
6472                timestamp: e.timestamp_utc.to_rfc3339(),
6473                commit: e.git_commit.clone(),
6474                branch: e.git_branch.clone(),
6475                tags,
6476                nearest_tag,
6477                code_lines: e.summary.code_lines,
6478                comment_lines: e.summary.comment_lines,
6479                blank_lines: e.summary.blank_lines,
6480                physical_lines: e.summary.total_physical_lines,
6481                files_analyzed: e.summary.files_analyzed,
6482                files_skipped: e.summary.files_skipped,
6483                test_count: e.summary.test_count,
6484                project_label: e.project_label.clone(),
6485                html_url,
6486                has_pdf,
6487                submodule_links,
6488                coverage_line_pct,
6489            };
6490            if let Some(ref filter) = submodule_filter {
6491                apply_submodule_filter(base, filter, &e)
6492            } else {
6493                Some(base)
6494            }
6495        })
6496        .collect();
6497
6498    Json(entries).into_response()
6499}
6500
6501// GET /api/metrics/submodules?root=<path>
6502// Returns the union of distinct submodule names found across all saved scan JSON artifacts
6503// for the given project root (or all roots if omitted).
6504#[derive(Deserialize)]
6505struct MetricsSubmodulesQuery {
6506    root: Option<String>,
6507}
6508
6509#[derive(Serialize)]
6510struct SubmoduleEntry {
6511    name: String,
6512    relative_path: String,
6513}
6514
6515async fn api_metrics_submodules_handler(
6516    State(state): State<AppState>,
6517    Query(query): Query<MetricsSubmodulesQuery>,
6518) -> Response {
6519    let json_paths: Vec<std::path::PathBuf> = {
6520        let reg = state.registry.lock().await;
6521        reg.entries
6522            .iter()
6523            .filter(|e| {
6524                query.root.as_ref().is_none_or(|root| {
6525                    let resolved = resolve_input_path(root);
6526                    let root_str = resolved.to_string_lossy().replace('\\', "/");
6527                    e.input_roots.iter().any(|r| r == &root_str)
6528                })
6529            })
6530            .filter_map(|e| e.json_path.clone())
6531            .collect()
6532    };
6533
6534    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6535    let mut result: Vec<SubmoduleEntry> = Vec::new();
6536
6537    for path in &json_paths {
6538        let Ok(json_str) = std::fs::read_to_string(path) else {
6539            continue;
6540        };
6541        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
6542            continue;
6543        };
6544        for sub in &run.submodule_summaries {
6545            if seen.insert(sub.name.clone()) {
6546                result.push(SubmoduleEntry {
6547                    name: sub.name.clone(),
6548                    relative_path: sub.relative_path.clone(),
6549                });
6550            }
6551        }
6552    }
6553
6554    result.sort_by(|a, b| a.name.cmp(&b.name));
6555    Json(result).into_response()
6556}
6557
6558// ── CI ingest endpoint ────────────────────────────────────────────────────────
6559// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
6560// server stores and displays results without cloning or scanning anything itself.
6561//
6562// POST /api/ingest?label=<optional_display_name>
6563// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
6564// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
6565
6566#[derive(Deserialize)]
6567struct IngestQuery {
6568    label: Option<String>,
6569}
6570
6571#[derive(Serialize)]
6572struct IngestResponse {
6573    run_id: String,
6574    view_url: String,
6575}
6576
6577async fn api_ingest_handler(
6578    State(state): State<AppState>,
6579    Query(q): Query<IngestQuery>,
6580    Json(run): Json<sloc_core::AnalysisRun>,
6581) -> Response {
6582    let label = q.label.unwrap_or_else(|| {
6583        run.input_roots
6584            .first()
6585            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
6586    });
6587
6588    let label_for_task = label.clone();
6589    let result = tokio::task::spawn_blocking(move || {
6590        let html = render_html(&run)?;
6591        let run_id = run.tool.run_id.clone();
6592        let run_id_safe = run_id.len() <= 128
6593            && !run_id.is_empty()
6594            && run_id
6595                .chars()
6596                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
6597        if !run_id_safe {
6598            anyhow::bail!(
6599                "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
6600            );
6601        }
6602        let project_label = sanitize_project_label(&label_for_task);
6603        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
6604        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
6605            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
6606            _ => project_label,
6607        };
6608        let (artifacts, _pending_pdf) = persist_run_artifacts(
6609            &run,
6610            &html,
6611            &output_dir,
6612            true,
6613            true,
6614            false,
6615            &label_for_task,
6616            &file_stem,
6617            RunResultContext::default(),
6618        )?;
6619        Ok::<_, anyhow::Error>((run_id, artifacts, run))
6620    })
6621    .await;
6622
6623    match result {
6624        Ok(Ok((run_id, artifacts, run))) => {
6625            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
6626            (
6627                StatusCode::CREATED,
6628                Json(IngestResponse {
6629                    view_url: format!("/view-reports?run_id={run_id}"),
6630                    run_id,
6631                }),
6632            )
6633                .into_response()
6634        }
6635        Ok(Err(e)) => error::internal(&format!("{e:#}")),
6636        Err(e) => error::internal(&format!("{e}")),
6637    }
6638}
6639
6640// ── Trend report page ─────────────────────────────────────────────────────────
6641// Protected. Interactive time-series chart page that loads scan history via
6642// /api/metrics/history and renders a vanilla-SVG line chart.
6643//
6644// GET /trend-reports
6645
6646#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
6647async fn trend_report_handler(
6648    State(state): State<AppState>,
6649    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6650) -> Response {
6651    auto_scan_watched_dirs(&state).await;
6652
6653    let watched_dirs_list: Vec<String> = {
6654        let wd = state.watched_dirs.lock().await;
6655        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6656    };
6657
6658    // Collect distinct project roots for the root selector dropdown.
6659    let roots: Vec<String> = {
6660        let reg = state.registry.lock().await;
6661        let mut seen = std::collections::BTreeSet::new();
6662        reg.entries
6663            .iter()
6664            .flat_map(|e| e.input_roots.iter().cloned())
6665            .filter(|r| seen.insert(r.clone()))
6666            .collect()
6667    };
6668
6669    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
6670    let nonce = &csp_nonce;
6671    let version = env!("CARGO_PKG_VERSION");
6672
6673    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
6674    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
6675    // of interactive controls — folder watching is managed by the host administrator.
6676    let watched_dirs_html: String = if state.server_mode {
6677        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()
6678    } else {
6679        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
6680            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
6681                .to_string()
6682        } else {
6683            watched_dirs_list
6684                .iter()
6685                .fold(String::new(), |mut s, d| {
6686                    use std::fmt::Write as _;
6687                    let escaped =
6688                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
6689                    write!(
6690                        s,
6691                        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>"#
6692                    ).expect("write to String is infallible");
6693                    s
6694                })
6695        };
6696        format!(
6697            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>"#
6698        )
6699    };
6700
6701    let html = format!(
6702        r##"<!doctype html>
6703<html lang="en">
6704<head>
6705  <meta charset="utf-8" />
6706  <meta name="viewport" content="width=device-width, initial-scale=1" />
6707  <title>OxideSLOC | Trend Reports</title>
6708  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6709  <style nonce="{nonce}">
6710    :root {{
6711      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
6712      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6713      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
6714      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6715      --info-bg:#eef3ff; --info-text:#4467d8;
6716    }}
6717    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
6718    *{{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;}}
6719    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
6720    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
6721    .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;}}
6722    @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));}}}}
6723    .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);}}
6724    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
6725    .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));}}
6726    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
6727    .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;}}
6728    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
6729    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
6730    @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; }} }}
6731    .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;}}
6732    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
6733    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
6734    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
6735    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
6736    .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;}}
6737    .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;}}
6738    .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;}}
6739    .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;}}
6740    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
6741    .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);}}
6742    .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;}}
6743    .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;}}
6744    .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;}}
6745    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
6746    .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;}}
6747    .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);}}
6748    .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;}}
6749    .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;}}
6750    .tz-select:focus{{border-color:var(--oxide);}}
6751    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
6752    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
6753    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
6754    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
6755    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
6756    .trend-title-block{{flex:1;min-width:0;}}
6757    .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;}}
6758    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
6759    .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;}}
6760    .chart-select:focus{{border-color:var(--accent);}}
6761    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
6762    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
6763    .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;}}
6764    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
6765    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
6766    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
6767    .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);}}
6768    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
6769    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
6770    .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;}}
6771    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
6772    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
6773    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
6774    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
6775    .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;}}
6776    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
6777    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
6778    .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);}}
6779    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
6780    .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;}}
6781    .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;}}
6782    .data-table tr:last-child td{{border-bottom:none;}}
6783    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
6784    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
6785    .table-wrap{{width:100%;overflow-x:auto;}}
6786    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
6787    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
6788    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
6789    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
6790    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
6791    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
6792    .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;}}
6793    .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;}}
6794    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
6795    .pagination-info{{font-size:13px;color:var(--muted);}}
6796    .pagination-btns{{display:flex;gap:6px;}}
6797    .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;}}
6798    .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;}}
6799    #scan-history-table col:nth-child(1){{width:155px;}}
6800    #scan-history-table col:nth-child(2){{width:240px;}}
6801    #scan-history-table col:nth-child(3){{width:82px;}}
6802    #scan-history-table col:nth-child(4){{width:82px;}}
6803    #scan-history-table col:nth-child(5){{width:90px;}}
6804    #scan-history-table col:nth-child(6){{width:90px;}}
6805    #scan-history-table col:nth-child(7){{width:88px;}}
6806    #scan-history-table col:nth-child(8){{width:150px;}}
6807    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
6808    .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;}}
6809    .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;}}
6810    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
6811    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
6812    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
6813    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
6814    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
6815    .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;}}
6816    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
6817    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
6818    .watched-chip-rm:hover{{color:var(--oxide);}}
6819    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
6820    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
6821    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
6822    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
6823    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
6824    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
6825    a.run-link:hover{{text-decoration:underline;}}
6826    .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);}}
6827    .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);}}
6828    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
6829    .metric-num{{font-weight:700;color:var(--text);}}
6830    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
6831    .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;}}
6832    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
6833    .btn.primary:hover{{opacity:.9;}}
6834    .rpt-btn{{min-width:58px;justify-content:center;}}
6835    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
6836    .report-cell{{overflow:visible!important;white-space:normal!important;}}
6837    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
6838    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
6839    .submod-details summary::-webkit-details-marker{{display:none;}}
6840    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
6841    .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;}}
6842    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
6843    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
6844    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
6845    .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;}}
6846    .export-btn:hover{{background:var(--line);}}
6847    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
6848    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
6849    .site-footer a{{color:var(--muted);}}
6850    .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;}}
6851    .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;}}
6852    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
6853  </style>
6854</head>
6855<body>
6856  <div class="background-watermarks" aria-hidden="true">
6857    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6858    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6859    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6860    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6861    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6862    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6863  </div>
6864  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6865  <div class="top-nav">
6866    <div class="top-nav-inner">
6867      <a class="brand" href="/">
6868        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6869        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
6870      </a>
6871      <div class="nav-right">
6872        <a class="nav-pill" href="/">Home</a>
6873        <div class="nav-dropdown">
6874          <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>
6875          <div class="nav-dropdown-menu">
6876            <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>
6877          </div>
6878        </div>
6879        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6880        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
6881        <div class="nav-dropdown">
6882          <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>
6883          <div class="nav-dropdown-menu">
6884            <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>
6885          </div>
6886        </div>
6887        <div class="server-status-wrap" id="server-status-wrap">
6888          <div class="nav-pill server-online-pill" id="server-status-pill">
6889            <span class="status-dot" id="status-dot"></span>
6890            <span id="server-status-label">Server</span>
6891            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
6892          </div>
6893          <div class="server-status-tip">
6894            OxideSLOC is running — accessible on your network.
6895            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
6896          </div>
6897        </div>
6898        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
6899          <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>
6900        </button>
6901        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6902          <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>
6903          <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>
6904        </button>
6905      </div>
6906    </div>
6907  </div>
6908
6909  <div class="page">
6910    {watched_dirs_html}
6911    <div class="summary-strip" id="trend-stats"></div>
6912    <div class="panel">
6913      <div class="trend-header">
6914        <div class="trend-title-block">
6915          <h1>Trend Reports</h1>
6916          <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>
6917          <span class="chart-hint-inline">
6918            <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>
6919            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
6920          </span>
6921        </div>
6922        <div class="chart-actions">
6923          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
6924            <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>
6925            Clean up old runs
6926          </button>
6927          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
6928            <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>
6929            Export Excel
6930          </button>
6931          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
6932            <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>
6933            Export PNG
6934          </button>
6935        </div>
6936      </div>
6937
6938      <div class="controls-centered">
6939        <label>Project Root:
6940          <select class="chart-select" id="root-sel">
6941            <option value="">All projects</option>
6942          </select>
6943        </label>
6944        <label>Y Metric:
6945          <select class="chart-select" id="y-sel">
6946            <option value="code_lines">Code Lines</option>
6947            <option value="comment_lines">Comment Lines</option>
6948            <option value="blank_lines">Blank Lines</option>
6949            <option value="physical_lines">Physical Lines</option>
6950            <option value="files_analyzed">Files Analyzed</option>
6951          </select>
6952        </label>
6953        <label>X Axis:
6954          <select class="chart-select" id="x-sel">
6955            <option value="time">By Time</option>
6956            <option value="commit">By Commit</option>
6957            <option value="release">By Release</option>
6958            <option value="tag">Tagged Commits</option>
6959          </select>
6960        </label>
6961        <label id="submodule-label" style="display:none;">Submodule:
6962          <select class="chart-select" id="sub-sel">
6963            <option value="">All (project total)</option>
6964          </select>
6965        </label>
6966        <label>Chart Size:
6967          <select class="chart-select" id="scale-sel">
6968            <option value="0.75">Compact</option>
6969            <option value="1.2" selected>Normal</option>
6970            <option value="1.38">Large</option>
6971          </select>
6972        </label>
6973      </div>
6974
6975      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
6976      <div id="data-table-wrap" style="overflow-x:auto;"></div>
6977    </div>
6978  </div>
6979
6980  <script nonce="{nonce}">
6981    (function() {{
6982      // Theme persistence
6983      var b = document.body;
6984      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
6985      var tgl = document.getElementById('theme-toggle');
6986      if (tgl) tgl.addEventListener('click', function() {{
6987        var d = b.classList.toggle('dark-theme');
6988        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
6989      }});
6990
6991      // Watermark randomizer
6992      (function() {{
6993        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6994        if (!wms.length) return;
6995        var placed = [];
6996        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;}}
6997        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];}}
6998        var half=Math.floor(wms.length/2);
6999        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;}});
7000      }})();
7001
7002      // Code particles
7003      (function() {{
7004        var container = document.getElementById('code-particles');
7005        if (!container) return;
7006        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'];
7007        for (var i = 0; i < 38; i++) {{
7008          (function(idx) {{
7009            var el = document.createElement('span');
7010            el.className = 'code-particle';
7011            el.textContent = snippets[idx % snippets.length];
7012            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7013            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7014            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7015            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';
7016            container.appendChild(el);
7017          }})(i);
7018        }}
7019      }})();
7020
7021      // Watched folder picker
7022      (function() {{
7023        var btn = document.getElementById('add-watched-btn');
7024        if (!btn) return;
7025        btn.addEventListener('click', function() {{
7026          fetch('/pick-directory?kind=reports')
7027            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
7028            .then(function(data) {{
7029              if (!data.cancelled && data.selected_path) {{
7030                var form = document.createElement('form');
7031                form.method = 'POST';
7032                form.action = '/watched-dirs/add';
7033                var ri = document.createElement('input');
7034                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7035                var fi = document.createElement('input');
7036                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7037                form.appendChild(ri); form.appendChild(fi);
7038                document.body.appendChild(form);
7039                form.submit();
7040              }}
7041            }})
7042            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7043        }});
7044      }})();
7045
7046      // Settings / color-scheme modal
7047      (function() {{
7048        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'}}];
7049        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);}});}}
7050        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7051        var btn=document.getElementById('settings-btn');if(!btn)return;
7052        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7053        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>';
7054        document.body.appendChild(m);
7055        var g=document.getElementById('scheme-grid');
7056        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);}});
7057        var cl=document.getElementById('settings-close');
7058        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);
7059        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');}});
7060        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7061        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7062      }})();
7063    }})();
7064
7065    var ROOTS = {roots_json};
7066    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7067    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
7068    var allData = [];
7069
7070    // Populate root selector
7071    var rootSel = document.getElementById('root-sel');
7072    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
7073
7074    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();}}
7075    function fmtFull(n){{return Number(n).toLocaleString();}}
7076    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
7077
7078    // Tooltip
7079    var tt = document.createElement('div');
7080    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);';
7081    document.body.appendChild(tt);
7082    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
7083    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';}}
7084    function hideTT(){{tt.style.display='none';}}
7085
7086    function statExact(compact, full){{
7087      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
7088    }}
7089    function statVal(n){{
7090      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
7091    }}
7092
7093    function updateStats(data){{
7094      var statsEl=document.getElementById('trend-stats');
7095      if(!statsEl)return;
7096      if(!data||!data.length){{statsEl.innerHTML='';return;}}
7097      var yKey=document.getElementById('y-sel').value;
7098      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7099      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7100      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
7101      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
7102      var absDelta=Math.abs(delta);
7103      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
7104      var deltaExact=statExact(deltaCompact,deltaFull);
7105      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
7106      statsEl.innerHTML=
7107        '<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>'+
7108        '<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>'+
7109        '<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>'+
7110        '<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>';
7111    }}
7112
7113    var subSel = document.getElementById('sub-sel');
7114    var subLabel = document.getElementById('submodule-label');
7115
7116    function populateSubmodules(root){{
7117      if(!subSel||!subLabel)return;
7118      while(subSel.options.length>1)subSel.remove(1);
7119      subSel.value='';
7120      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
7121      fetch(url)
7122        .then(function(r){{return r.json();}})
7123        .then(function(subs){{
7124          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
7125          subs.forEach(function(s){{
7126            var o=document.createElement('option');
7127            o.value=s.name;
7128            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
7129            subSel.appendChild(o);
7130          }});
7131          subLabel.style.display='';
7132        }})
7133        .catch(function(){{subLabel.style.display='none';}});
7134    }}
7135
7136    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div>';
7137
7138    function loadAndRender(){{
7139      var root = rootSel.value;
7140      var sub = subSel ? subSel.value : '';
7141      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
7142      document.getElementById('data-table-wrap').innerHTML='';
7143      var url = '/api/metrics/history?limit=100'
7144        + (root ? '&root='+encodeURIComponent(root) : '')
7145        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
7146      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
7147        allData = data;
7148        render(data);
7149        updateStats(data);
7150      }}).catch(function(){{
7151        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>';
7152      }});
7153    }}
7154
7155    function render(data){{
7156      var yKey = document.getElementById('y-sel').value;
7157      var xMode = document.getElementById('x-sel').value;
7158
7159      // Filter for tag/release mode
7160      var pts = data;
7161      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
7162
7163      // Sort oldest-first for the line chart
7164      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7165
7166      var wrap = document.getElementById('chart-wrap');
7167      if(!pts.length){{
7168        var emptyMsg = (xMode === 'tag')
7169          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
7170          : 'No scan data found for the selected filters.';
7171        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
7172        renderTable([]);
7173        return;
7174      }}
7175
7176      var scaleEl=document.getElementById('scale-sel');
7177      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
7178      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;
7179      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
7180
7181      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7182
7183      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">';
7184      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>';
7185
7186      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
7187
7188      // Grid + Y axis ticks
7189      for(var ti=0;ti<=5;ti++){{
7190        var gy=PT+CH-Math.round(ti/5*CH);
7191        var gv=Math.round(ti/5*maxY);
7192        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
7193        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
7194      }}
7195
7196      // X axis labels (every N-th point to avoid crowding)
7197      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
7198      pts.forEach(function(d,i){{
7199        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7200        if(i%labelEvery===0||i===pts.length-1){{
7201          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)));
7202          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>';
7203        }}
7204      }});
7205
7206      // Axis label
7207      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
7208      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>';
7209      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>';
7210
7211      // Area fill + line path
7212      var pathD='';
7213      pts.forEach(function(d,i){{
7214        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7215        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7216        pathD+=(i===0?'M':'L')+x+','+y;
7217      }});
7218      if(pts.length>1){{
7219        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
7220        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
7221      }}
7222      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
7223
7224      // Data points (clickable) + permanent value labels
7225      var showLabels = pts.length <= 40;
7226      var labelEveryN = pts.length > 20 ? 2 : 1;
7227      pts.forEach(function(d,i){{
7228        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7229        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7230        var hasTags=d.tags&&d.tags.length>0;
7231        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
7232        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
7233        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+'"/>';
7234        if(showLabels && i%labelEveryN===0){{
7235          var lx=x, ly=y-r-5;
7236          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>';
7237        }}
7238      }});
7239
7240      svg+='</svg>';
7241      wrap.innerHTML=svg;
7242
7243      // Attach point tooltips
7244      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
7245        c.addEventListener('mouseover',function(e){{
7246          var d=pts[parseInt(this.dataset.idx)];
7247          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(''):'';
7248          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>':'';
7249          showTT(e,
7250            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
7251            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
7252            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
7253            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
7254          );
7255          this.setAttribute('r','8');
7256        }});
7257        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
7258        c.addEventListener('mousemove',moveTT);
7259        c.addEventListener('click',function(){{
7260          var d=pts[parseInt(this.dataset.idx)];
7261          if(d.html_url) window.open(d.html_url,'_blank');
7262        }});
7263      }});
7264
7265      renderTable(pts, yKey);
7266    }}
7267
7268    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
7269    var shProjFilter='', shBranchFilter='';
7270
7271    function fmtPST(isoStr){{
7272      if(!isoStr)return'';
7273      var d=new Date(isoStr);
7274      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
7275      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);}}
7276      function p(n){{return n<10?'0'+n:String(n);}}
7277      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++;}}}}
7278      var yr=d.getUTCFullYear();
7279      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
7280      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
7281      var isDST=d>=dstStart&&d<dstEnd;
7282      var off=isDST?-7*3600*1000:-8*3600*1000;
7283      var lbl=isDST?'PDT':'PST';
7284      var loc=new Date(d.getTime()+off);
7285      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
7286    }}
7287
7288    function getShRows(){{
7289      var proj=shProjFilter.toLowerCase().trim();
7290      var branch=shBranchFilter;
7291      return shData.filter(function(d){{
7292        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
7293        if(branch&&(d.branch||'')!==branch)return false;
7294        return true;
7295      }});
7296    }}
7297
7298    function renderShPage(){{
7299      var filtered=getShRows();
7300      if(shSortCol){{
7301        filtered.sort(function(a,b){{
7302          var va,vb;
7303          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
7304          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
7305          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
7306          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
7307          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
7308          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
7309        }});
7310      }}
7311      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
7312      shPage=Math.min(shPage,totalPages);
7313      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
7314      var visible=filtered.slice(start,end);
7315      var tbody=document.getElementById('sh-tbody');
7316      if(!tbody)return;
7317      tbody.innerHTML=visible.map(function(d){{
7318        var tsHtml=esc(fmtPST(d.timestamp));
7319        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>';
7320        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>';
7321        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
7322        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
7323        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
7324        var reportCell='';
7325        if(d.html_url){{
7326          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
7327          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>';}}
7328          reportCell+='</div>';
7329        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
7330        if(d.submodule_links&&d.submodule_links.length){{
7331          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
7332          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
7333          reportCell+='</div></details>';
7334        }}
7335        return '<tr>'
7336          +'<td>'+tsHtml+'</td>'
7337          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
7338          +'<td>'+runIdHtml+'</td>'
7339          +'<td>'+commitHtml+'</td>'
7340          +'<td>'+branchHtml+'</td>'
7341          +'<td>'+tags+'</td>'
7342          +'<td class="num">'+metricHtml+'</td>'
7343          +'<td class="report-cell">'+reportCell+'</td>'
7344          +'</tr>';
7345      }}).join('');
7346      var pgRange=document.getElementById('sh-pg-range');
7347      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'–'+end+' of '+total:'No results';
7348      var pgInfo=document.getElementById('sh-pg-info');
7349      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
7350      var pgBtns=document.getElementById('sh-pg-btns');
7351      if(pgBtns){{
7352        pgBtns.innerHTML='';
7353        function mkPgBtn(lbl,pg,active,disabled){{
7354          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
7355          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
7356          return b;
7357        }}
7358        pgBtns.appendChild(mkPgBtn('‹',shPage-1,false,shPage===1));
7359        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
7360        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
7361        pgBtns.appendChild(mkPgBtn('›',shPage+1,false,shPage===totalPages));
7362      }}
7363    }}
7364
7365    function wireTableBehavior(){{
7366      var pf=document.getElementById('sh-proj-filter');
7367      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
7368      var bf=document.getElementById('sh-branch-filter');
7369      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
7370      var rb=document.getElementById('sh-reset-btn');
7371      if(rb)rb.addEventListener('click',function(){{
7372        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
7373        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
7374        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
7375        document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
7376        renderShPage();
7377      }});
7378      var pps=document.getElementById('sh-per-page');
7379      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
7380      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
7381      ths.forEach(function(th){{
7382        th.addEventListener('click',function(e){{
7383          if(e.target.classList.contains('col-resize-handle'))return;
7384          var col=th.dataset.col;
7385          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
7386          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
7387          th.classList.add('sort-'+shSortOrder);
7388          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
7389          shPage=1;renderShPage();
7390        }});
7391      }});
7392      var table=document.getElementById('scan-history-table');
7393      if(!table)return;
7394      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
7395      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
7396      allThs.forEach(function(th,i){{
7397        var handle=th.querySelector('.col-resize-handle');
7398        if(!handle||!cols[i])return;
7399        var startX,startW;
7400        handle.addEventListener('mousedown',function(e){{
7401          e.stopPropagation();e.preventDefault();
7402          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
7403          handle.classList.add('dragging');
7404          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
7405          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
7406          document.addEventListener('mousemove',onMove);
7407          document.addEventListener('mouseup',onUp);
7408        }});
7409      }});
7410    }}
7411
7412    function renderTable(pts, yKey){{
7413      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
7414      var wrap=document.getElementById('data-table-wrap');
7415      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
7416      var yLabel=Y_LABELS[yKey]||yKey||'';
7417      shData=pts.slice().reverse();
7418      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
7419      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
7420      var branches={{}};
7421      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
7422      var branchOpts='<option value="">All branches</option>';
7423      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
7424      wrap.innerHTML=
7425        '<div class="chart-section-header">SCAN HISTORY</div>'+
7426        '<div class="filter-row">'+
7427          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project…">'+
7428          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
7429          '<button type="button" class="btn" id="sh-reset-btn">↻ Reset view</button>'+
7430        '</div>'+
7431        '<div class="table-wrap">'+
7432        '<table id="scan-history-table" class="data-table">'+
7433        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
7434        '<thead><tr id="sh-thead">'+
7435        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7436        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7437        '<th>Run ID<div class="col-resize-handle"></div></th>'+
7438        '<th>Commit<div class="col-resize-handle"></div></th>'+
7439        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7440        '<th>Tags<div class="col-resize-handle"></div></th>'+
7441        '<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>'+
7442        '<th>Report<div class="col-resize-handle"></div></th>'+
7443        '</tr></thead>'+
7444        '<tbody id="sh-tbody"></tbody>'+
7445        '</table>'+
7446        '</div>'+
7447        '<div class="pagination">'+
7448          '<span class="pagination-info" id="sh-pg-info"></span>'+
7449          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
7450          '<div style="display:flex;align-items:center;gap:8px;">'+
7451            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
7452            '<select class="filter-select" id="sh-per-page">'+
7453              '<option value="10">10 per page</option>'+
7454              '<option value="25" selected>25 per page</option>'+
7455              '<option value="50">50 per page</option>'+
7456              '<option value="100">100 per page</option>'+
7457            '</select>'+
7458            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
7459          '</div>'+
7460        '</div>';
7461      wireTableBehavior();
7462      renderShPage();
7463    }}
7464
7465    function exportXLSX(){{
7466      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
7467      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
7468      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
7469      var s1R=sorted.map(function(d){{
7470        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||''];
7471      }});
7472      var pm={{}};
7473      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
7474      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'];
7475      var s2R=Object.keys(pm).map(function(p){{
7476        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7477        var lat=sc[sc.length-1],fst=sc[0];
7478        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
7479        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);
7480        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];
7481      }});
7482      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
7483      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
7484      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
7485      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
7486    }}
7487
7488    function buildXLSX(sheets,chartRows,chartRows2){{
7489      function s2b(s){{return new TextEncoder().encode(s);}}
7490      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
7491      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;}}
7492      function crc32(d){{
7493        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;}}}}
7494        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
7495      }}
7496      function buildSheet(hdr,rows,drawRid,withCtrl){{
7497        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
7498        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
7499        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
7500        x+='<row r="1">';
7501        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
7502        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>';}}
7503        x+='</row>';
7504        rows.forEach(function(row,ri){{
7505          var rn=ri+2;
7506          x+='<row r="'+rn+'">';
7507          row.forEach(function(cell,ci){{
7508            var addr=col2l(ci+1)+rn;
7509            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
7510            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
7511          }});
7512          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>';}}
7513          x+='</row>';
7514        }});
7515        x+='</sheetData>';
7516        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>';}}
7517        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
7518        return x+'</worksheet>';
7519      }}
7520      function buildChartXML(rows){{
7521        var sn="'Scan History'";
7522        var nr=rows.length,er=nr+1;
7523        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'}}];
7524        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7525        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">';
7526        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7527        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7528        sd.forEach(function(s,i){{
7529          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7530          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>';
7531          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7532          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>';
7533          var dlp=(i===2)?'b':'t';
7534          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>';
7535          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7536          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7537          x+='</c:strCache></c:strRef></c:cat>';
7538          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+'"/>';
7539          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7540          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7541        }});
7542        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
7543        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>';
7544        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>';
7545        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7546        return x;
7547      }}
7548      function buildChartXML2(rows){{
7549        var sn="'By Project'";
7550        var nr=rows.length,er=nr+1;
7551        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'}}];
7552        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7553        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">';
7554        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7555        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7556        sd.forEach(function(s,i){{
7557          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7558          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>';
7559          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7560          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>';
7561          var dlp=(i===2)?'b':'t';
7562          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>';
7563          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7564          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7565          x+='</c:strCache></c:strRef></c:cat>';
7566          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+'"/>';
7567          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7568          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7569        }});
7570        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
7571        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>';
7572        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>';
7573        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7574        return x;
7575      }}
7576      function buildChartXML3(rows){{
7577        var sn="'Scan History'";
7578        var nr=rows.length,er=nr+1;
7579        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7580        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">';
7581        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
7582        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7583        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
7584        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>';
7585        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
7586        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>';
7587        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>';
7588        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7589        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7590        x+='</c:strCache></c:strRef></c:cat>';
7591        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+'"/>';
7592        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
7593        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7594        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
7595        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>';
7596        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>';
7597        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>';
7598        return x;
7599      }}
7600      var hasChart=!!(chartRows&&chartRows.length);
7601      var nr=hasChart?chartRows.length:0;
7602      var hasChart2=!!(chartRows2&&chartRows2.length);
7603      var nr2=hasChart2?chartRows2.length:0;
7604      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>';
7605      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"/>';
7606      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
7607      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"/>';}}
7608      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"/>';}}
7609      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
7610      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>';
7611      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
7612      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"/>';}});
7613      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
7614      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>';
7615      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
7616      wbx+='</sheets></workbook>';
7617      var files=[
7618        {{name:'[Content_Types].xml',data:s2b(ct)}},
7619        {{name:'_rels/.rels',data:s2b(dotrels)}},
7620        {{name:'xl/workbook.xml',data:s2b(wbx)}},
7621        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
7622        {{name:'xl/styles.xml',data:s2b(styl)}}
7623      ];
7624      // Chart embedded directly in Scan History (sheet1); By Project is plain
7625      sheets.forEach(function(s,i){{
7626        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)))}});
7627      }});
7628      if(hasChart){{
7629        var fromRow=nr+4,toRow=nr+24;
7630        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>')}});
7631        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7632        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">';
7633        drx+='<xdr:twoCellAnchor editAs="twoCell">';
7634        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>';
7635        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>';
7636        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7637        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7638        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7639        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7640        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
7641        var focRow=toRow+2,focRowEnd=toRow+22;
7642        drx+='<xdr:twoCellAnchor editAs="twoCell">';
7643        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>';
7644        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>';
7645        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7646        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7647        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7648        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
7649        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
7650        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
7651        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>')}});
7652        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
7653        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
7654      }}
7655      if(hasChart2){{
7656        var fromRow2=nr2+4,toRow2=nr2+24;
7657        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>')}});
7658        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7659        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">';
7660        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
7661        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>';
7662        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>';
7663        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7664        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7665        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7666        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7667        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
7668        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
7669        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>')}});
7670        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
7671      }}
7672      var parts=[],offsets=[],total=0;
7673      files.forEach(function(f){{
7674        offsets.push(total);
7675        var nb=s2b(f.name),crc=crc32(f.data);
7676        var h=new DataView(new ArrayBuffer(30+nb.length));
7677        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
7678        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
7679        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
7680        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
7681        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
7682        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
7683        total+=30+nb.length+f.data.length;
7684      }});
7685      var cdStart=total;
7686      files.forEach(function(f,fi){{
7687        var nb=s2b(f.name),crc=crc32(f.data);
7688        var cd=new DataView(new ArrayBuffer(46+nb.length));
7689        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
7690        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
7691        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
7692        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
7693        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
7694        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
7695        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
7696      }});
7697      var cdSz=total-cdStart;
7698      var eocd=new DataView(new ArrayBuffer(22));
7699      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
7700      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
7701      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
7702      parts.push(new Uint8Array(eocd.buffer));
7703      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
7704      var out=new Uint8Array(sz);var off=0;
7705      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
7706      return out.buffer;
7707    }}
7708
7709    function exportPNG(){{
7710      var svgEl=document.querySelector('#chart-wrap svg');
7711      if(!svgEl){{alert('No chart to export yet.');return;}}
7712      var svgStr=new XMLSerializer().serializeToString(svgEl);
7713      var vb=svgEl.viewBox.baseVal,scale=2;
7714      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
7715      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
7716      var url=URL.createObjectURL(blob);
7717      var img=new Image();
7718      img.onload=function(){{
7719        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
7720        var ctx=canvas.getContext('2d');
7721        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
7722        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
7723        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
7724        URL.revokeObjectURL(url);
7725        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
7726      }};
7727      img.src=url;
7728    }}
7729
7730    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
7731      var el=document.getElementById(id);
7732      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
7733    }});
7734    rootSel.addEventListener('change',function(){{
7735      populateSubmodules(rootSel.value);
7736      loadAndRender();
7737    }});
7738    if(subSel)subSel.addEventListener('change',loadAndRender);
7739
7740    var xlsxBtn=document.getElementById('export-xlsx-btn');
7741    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
7742    var pngBtn=document.getElementById('export-png-btn');
7743    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
7744
7745    // ── Clean-up modal ───────────────────────────────────────────────────────
7746    (function(){{
7747      var triggerBtn=document.getElementById('cleanup-runs-btn');
7748      if(!triggerBtn)return;
7749      var modal=document.createElement('div');
7750      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;';
7751      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);">'
7752        +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
7753        +'<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>'
7754        +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
7755        +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
7756        +'<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;">'
7757        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
7758        +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
7759        +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
7760        +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
7761        +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
7762        +'</div></div>';
7763      document.body.appendChild(modal);
7764      triggerBtn.addEventListener('click',function(){{
7765        document.getElementById('cleanup-status').style.display='none';
7766        modal.style.display='flex';
7767      }});
7768      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
7769      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
7770      document.getElementById('cleanup-confirm-btn').addEventListener('click',async function(){{
7771        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
7772        var confirmBtn=this;
7773        confirmBtn.disabled=true;
7774        var status=document.getElementById('cleanup-status');
7775        status.style.display='block';
7776        status.style.background='#dbeafe';status.style.color='#1e40af';
7777        status.textContent='Deleting…';
7778        try{{
7779          var resp=await fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}});
7780          var d=await resp.json();
7781          if(resp.ok){{
7782            status.style.background='#dcfce7';status.style.color='#166534';
7783            status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing…';
7784            setTimeout(function(){{window.location.reload();}},1500);
7785          }}else{{
7786            status.style.background='#fee2e2';status.style.color='#991b1b';
7787            status.textContent='Error: '+(d.error||'Unexpected error');
7788            confirmBtn.disabled=false;
7789          }}
7790        }}catch(e){{
7791          status.style.background='#fee2e2';status.style.color='#991b1b';
7792          status.textContent='Network error: '+String(e);
7793          confirmBtn.disabled=false;
7794        }}
7795      }});
7796    }})();
7797
7798    populateSubmodules(rootSel.value);
7799    loadAndRender();
7800
7801    (function randomizeWatermarks() {{
7802      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7803      if (!wms.length) return;
7804      var placed = [];
7805      function tooClose(top, left) {{
7806        for (var i = 0; i < placed.length; i++) {{
7807          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
7808          if (dt < 16 && dl < 12) return true;
7809        }}
7810        return false;
7811      }}
7812      function pick(leftBand) {{
7813        for (var attempt = 0; attempt < 50; attempt++) {{
7814          var top = Math.random() * 88 + 2;
7815          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7816          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
7817        }}
7818        var top = Math.random() * 88 + 2;
7819        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7820        placed.push([top, left]); return [top, left];
7821      }}
7822      var half = Math.floor(wms.length / 2);
7823      wms.forEach(function (img, i) {{
7824        var pos = pick(i < half);
7825        var size = Math.floor(Math.random() * 100 + 120);
7826        var rot = (Math.random() * 360).toFixed(1);
7827        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
7828        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;
7829      }});
7830    }})();
7831    (function spawnCodeParticles() {{
7832      var container = document.getElementById('code-particles');
7833      if (!container) return;
7834      var snippets = [
7835        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
7836        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
7837        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
7838        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
7839        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
7840      ];
7841      var count = 38;
7842      for (var i = 0; i < count; i++) {{
7843        (function(idx) {{
7844          var el = document.createElement('span');
7845          el.className = 'code-particle';
7846          el.textContent = snippets[idx % snippets.length];
7847          var left = Math.random() * 94 + 2;
7848          var top = Math.random() * 88 + 6;
7849          var dur = (Math.random() * 10 + 9).toFixed(1);
7850          var delay = (Math.random() * 18).toFixed(1);
7851          var rot = (Math.random() * 26 - 13).toFixed(1);
7852          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7853          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
7854          container.appendChild(el);
7855        }})(i);
7856      }}
7857    }})();
7858  </script>
7859  <footer class="site-footer">
7860    local code analysis - metrics, history and reports
7861    &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>
7862    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7863    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7864    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7865    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
7866  </footer>
7867  <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>
7868</body>
7869</html>"##,
7870    );
7871
7872    Html(html).into_response()
7873}
7874
7875fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
7876    use std::collections::HashMap;
7877    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
7878        return vec![];
7879    }
7880    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
7881    for rec in per_file_records {
7882        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
7883            let e = totals.entry(lang.display_name().to_string()).or_default();
7884            e.0 += u64::from(cov.lines_found);
7885            e.1 += u64::from(cov.lines_hit);
7886        }
7887    }
7888    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
7889    let mut pairs: Vec<(String, f64)> = totals
7890        .into_iter()
7891        .filter(|(_, (found, _))| *found > 0)
7892        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
7893        .collect();
7894    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7895    pairs
7896        .iter()
7897        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
7898        .collect()
7899}
7900
7901fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
7902    let mut high = 0u64;
7903    let mut mid = 0u64;
7904    let mut low = 0u64;
7905    for rec in per_file_records {
7906        if let Some(cov) = &rec.coverage {
7907            if cov.lines_found == 0 {
7908                continue;
7909            }
7910            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
7911            if pct >= 80.0 {
7912                high += 1;
7913            } else if pct >= 50.0 {
7914                mid += 1;
7915            } else {
7916                low += 1;
7917            }
7918        }
7919    }
7920    (high, mid, low)
7921}
7922
7923fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
7924    let mut arr: Vec<serde_json::Value> = per_file_records
7925        .iter()
7926        .filter_map(|rec| {
7927            rec.coverage.as_ref().map(|cov| {
7928                let line_pct = if cov.lines_found > 0 {
7929                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
7930                        / 10.0
7931                } else {
7932                    0.0
7933                };
7934                let fn_pct = if cov.functions_found > 0 {
7935                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
7936                        .round()
7937                        / 10.0
7938                } else {
7939                    -1.0
7940                };
7941                serde_json::json!({
7942                    "rel": rec.relative_path,
7943                    "lang": rec.language.map_or("?", |l| l.display_name()),
7944                    "line_pct": line_pct,
7945                    "fn_pct": fn_pct,
7946                    "lhit": cov.lines_hit,
7947                    "lfound": cov.lines_found,
7948                    "fhit": cov.functions_hit,
7949                    "ffound": cov.functions_found,
7950                })
7951            })
7952        })
7953        .collect();
7954    arr.sort_by(|a, b| {
7955        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
7956        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
7957        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
7958    });
7959    arr
7960}
7961
7962#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
7963fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
7964    let mut langs: Vec<&sloc_core::LanguageSummary> = run
7965        .totals_by_language
7966        .iter()
7967        .filter(|l| l.test_count > 0)
7968        .collect();
7969    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
7970    let lang_tests: Vec<serde_json::Value> = langs
7971        .iter()
7972        .map(|l| {
7973            let d = if l.code_lines > 0 {
7974                l.test_count as f64 / l.code_lines as f64 * 1000.0
7975            } else {
7976                0.0
7977            };
7978            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
7979                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
7980                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
7981        })
7982        .collect();
7983    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
7984    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
7985    let t = &run.summary_totals;
7986    let total_tests = t.test_count;
7987    let density = if t.code_lines > 0 {
7988        total_tests as f64 / t.code_lines as f64 * 1000.0
7989    } else {
7990        0.0
7991    };
7992    let most_tested = langs.first().map_or_else(
7993        || "\u{2014}".to_string(),
7994        |l| l.language.display_name().to_string(),
7995    );
7996    let test_files: u64 = run
7997        .per_file_records
7998        .iter()
7999        .filter(|f| f.raw_line_categories.test_count > 0)
8000        .count() as u64;
8001    let cov_line = if t.coverage_lines_found > 0 {
8002        format!(
8003            "{:.1}",
8004            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
8005        )
8006    } else {
8007        "0".to_string()
8008    };
8009    let cov_fn = if t.coverage_functions_found > 0 {
8010        format!(
8011            "{:.1}",
8012            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
8013        )
8014    } else {
8015        "0".to_string()
8016    };
8017    let cov_branch = if t.coverage_branches_found > 0 {
8018        format!(
8019            "{:.1}",
8020            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
8021        )
8022    } else {
8023        "0".to_string()
8024    };
8025    let has_cov = !cov_arr.is_empty();
8026    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
8027    serde_json::json!({
8028        "totals": {
8029            "test_count": total_tests,
8030            "assertions": t.test_assertion_count,
8031            "suites": t.test_suite_count,
8032            "test_files": test_files,
8033            "total_files": t.files_analyzed,
8034            "density_str": format!("{density:.1}"),
8035            "most_tested": most_tested,
8036            "langs_with_tests": langs.len(),
8037            "cov_line": cov_line,
8038            "cov_fn": cov_fn,
8039            "cov_branch": cov_branch,
8040        },
8041        "lang_tests": lang_tests,
8042        "cov": cov_arr,
8043        "cov_tiers": {"high": high, "mid": mid, "low": low},
8044        "file_cov": file_cov_arr,
8045        "has_coverage": has_cov,
8046        "submodules": {},
8047    })
8048}
8049
8050#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
8051fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
8052    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
8053        .language_summaries
8054        .iter()
8055        .filter(|l| l.test_count > 0)
8056        .collect();
8057    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8058    let lang_tests: Vec<serde_json::Value> = langs
8059        .iter()
8060        .map(|l| {
8061            let d = if l.code_lines > 0 {
8062                l.test_count as f64 / l.code_lines as f64 * 1000.0
8063            } else {
8064                0.0
8065            };
8066            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8067                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8068                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8069        })
8070        .collect();
8071    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
8072    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
8073    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
8074    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
8075    let density = if sub.code_lines > 0 {
8076        total_tests as f64 / sub.code_lines as f64 * 1000.0
8077    } else {
8078        0.0
8079    };
8080    let most_tested = langs.first().map_or_else(
8081        || "\u{2014}".to_string(),
8082        |l| l.language.display_name().to_string(),
8083    );
8084    serde_json::json!({
8085        "totals": {
8086            "test_count": total_tests,
8087            "assertions": total_assertions,
8088            "suites": total_suites,
8089            "test_files": test_files_approx,
8090            "total_files": sub.files_analyzed,
8091            "density_str": format!("{density:.1}"),
8092            "most_tested": most_tested,
8093            "langs_with_tests": langs.len(),
8094            "cov_line": "0",
8095            "cov_fn": "0",
8096            "cov_branch": "0",
8097        },
8098        "lang_tests": lang_tests,
8099        "cov": [],
8100        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
8101        "has_coverage": false,
8102    })
8103}
8104
8105fn compute_cov_json_str(run: &AnalysisRun) -> String {
8106    use std::collections::HashMap;
8107    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
8108    for rec in &run.per_file_records {
8109        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
8110            let e = totals.entry(lang.display_name().to_string()).or_default();
8111            e.0 += u64::from(cov.lines_found);
8112            e.1 += u64::from(cov.lines_hit);
8113        }
8114    }
8115    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
8116    let mut pairs: Vec<(String, f64)> = totals
8117        .into_iter()
8118        .filter(|(_, (found, _))| *found > 0)
8119        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
8120        .collect();
8121    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8122    let parts: Vec<String> = pairs
8123        .iter()
8124        .map(|(lang, pct)| {
8125            let name = lang.replace('"', "\\\"");
8126            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
8127        })
8128        .collect();
8129    format!("[{}]", parts.join(","))
8130}
8131
8132fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
8133    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8134    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
8135}
8136
8137fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
8138    let mut entry = build_test_scope_entry(run);
8139    if !run.submodule_summaries.is_empty() {
8140        let subs: serde_json::Map<String, serde_json::Value> = run
8141            .submodule_summaries
8142            .iter()
8143            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
8144            .collect();
8145        entry["submodules"] = serde_json::Value::Object(subs);
8146    }
8147    entry
8148}
8149
8150fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
8151    let name = l.language.display_name().replace('"', "\\\"");
8152    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
8153    let density = if l.code_lines > 0 {
8154        l.test_count as f64 / l.code_lines as f64 * 1000.0
8155    } else {
8156        0.0
8157    };
8158    format!(
8159        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
8160        name = name,
8161        t = l.test_count,
8162        a = l.test_assertion_count,
8163        s = l.test_suite_count,
8164        c = l.code_lines,
8165        d = density,
8166        f = l.files,
8167    )
8168}
8169
8170fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
8171    let Some(r) = run else {
8172        return "[]".to_string();
8173    };
8174    let mut langs: Vec<&sloc_core::LanguageSummary> = r
8175        .totals_by_language
8176        .iter()
8177        .filter(|l| l.test_count > 0)
8178        .collect();
8179    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8180    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
8181    format!("[{}]", parts.join(","))
8182}
8183
8184// GET /test-metrics
8185#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
8186#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
8187async fn test_metrics_handler(
8188    State(state): State<AppState>,
8189    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8190) -> Response {
8191    auto_scan_watched_dirs(&state).await;
8192    let watched_dirs_list: Vec<String> = {
8193        let wd = state.watched_dirs.lock().await;
8194        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8195    };
8196    let latest_run: Option<AnalysisRun> = {
8197        let reg = state.registry.lock().await;
8198        let json_str: Option<String> = reg
8199            .entries
8200            .first()
8201            .and_then(|e| e.json_path.as_ref())
8202            .and_then(|p| std::fs::read_to_string(p).ok());
8203        drop(reg);
8204        json_str
8205            .as_deref()
8206            .and_then(|s| serde_json::from_str(s).ok())
8207    };
8208
8209    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
8210    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
8211
8212    // Build coverage chart JSON (per-language avg line coverage %).
8213    let cov_json: String = latest_run
8214        .as_ref()
8215        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8216        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
8217
8218    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
8219    let _cov_tier_json: String = latest_run
8220        .as_ref()
8221        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8222        .map_or_else(
8223            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
8224            compute_cov_tier_json_str,
8225        );
8226
8227    let total_tests: u64 = latest_run
8228        .as_ref()
8229        .map_or(0, |r| r.summary_totals.test_count);
8230    let total_assertions: u64 = latest_run
8231        .as_ref()
8232        .map_or(0, |r| r.summary_totals.test_assertion_count);
8233    let total_suites: u64 = latest_run
8234        .as_ref()
8235        .map_or(0, |r| r.summary_totals.test_suite_count);
8236    let total_code: u64 = latest_run
8237        .as_ref()
8238        .map_or(0, |r| r.summary_totals.code_lines);
8239    let workspace_density: f64 = if total_code > 0 {
8240        total_tests as f64 / total_code as f64 * 1000.0
8241    } else {
8242        0.0
8243    };
8244    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
8245        r.totals_by_language
8246            .iter()
8247            .filter(|l| l.test_count > 0)
8248            .count()
8249    });
8250    let most_tested: String = latest_run
8251        .as_ref()
8252        .and_then(|r| {
8253            r.totals_by_language
8254                .iter()
8255                .filter(|l| l.test_count > 0)
8256                .max_by_key(|l| l.test_count)
8257        })
8258        .map_or_else(
8259            || "\u{2014}".to_string(),
8260            |l| l.language.display_name().to_string(),
8261        );
8262    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
8263        r.per_file_records
8264            .iter()
8265            .filter(|f| f.raw_line_categories.test_count > 0)
8266            .count() as u64
8267    });
8268    let total_files_analyzed: u64 = latest_run
8269        .as_ref()
8270        .map_or(0, |r| r.summary_totals.files_analyzed);
8271    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
8272
8273    // Aggregated coverage percentages from summary_totals
8274    let cov_line_pct_str: String = latest_run
8275        .as_ref()
8276        .filter(|r| r.summary_totals.coverage_lines_found > 0)
8277        .map_or_else(
8278            || "0".to_string(),
8279            |r| {
8280                format!(
8281                    "{:.1}",
8282                    r.summary_totals.coverage_lines_hit as f64
8283                        / r.summary_totals.coverage_lines_found as f64
8284                        * 100.0
8285                )
8286            },
8287        );
8288    let cov_fn_pct_str: String = latest_run
8289        .as_ref()
8290        .filter(|r| r.summary_totals.coverage_functions_found > 0)
8291        .map_or_else(
8292            || "0".to_string(),
8293            |r| {
8294                format!(
8295                    "{:.1}",
8296                    r.summary_totals.coverage_functions_hit as f64
8297                        / r.summary_totals.coverage_functions_found as f64
8298                        * 100.0
8299                )
8300            },
8301        );
8302    let cov_branch_pct_str: String = latest_run
8303        .as_ref()
8304        .filter(|r| r.summary_totals.coverage_branches_found > 0)
8305        .map_or_else(
8306            || "0".to_string(),
8307            |r| {
8308                format!(
8309                    "{:.1}",
8310                    r.summary_totals.coverage_branches_hit as f64
8311                        / r.summary_totals.coverage_branches_found as f64
8312                        * 100.0
8313                )
8314            },
8315        );
8316
8317    let cov_no_data_notice = if has_coverage {
8318        String::new()
8319    } else {
8320        String::from(
8321            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
8322<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>
8323<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
8324  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
8325  <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>
8326  <span style="color:var(--muted);font-size:12px;">&middot;</span>
8327  <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>
8328  <span style="color:var(--muted);font-size:12px;">&middot;</span>
8329  <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>
8330</div>
8331<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
8332</div>"#,
8333        )
8334    };
8335
8336    let workspace_density_str = format!("{workspace_density:.1}");
8337    let nonce = &csp_nonce;
8338    let version = env!("CARGO_PKG_VERSION");
8339
8340    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
8341    // of interactive controls — folder watching is managed by the host administrator.
8342    let watched_dirs_html: String = if state.server_mode {
8343        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()
8344    } else {
8345        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
8346            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
8347                .to_string()
8348        } else {
8349            watched_dirs_list
8350                .iter()
8351                .fold(String::new(), |mut s, d| {
8352                    use std::fmt::Write as _;
8353                    let escaped =
8354                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
8355                    write!(
8356                        s,
8357                        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>"#
8358                    ).expect("write to String is infallible");
8359                    s
8360                })
8361        };
8362        format!(
8363            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>"#
8364        )
8365    };
8366
8367    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
8368    let scope_data_json: String = {
8369        let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
8370        scope_map.insert(
8371            "__all__".to_string(),
8372            latest_run.as_ref().map_or_else(
8373                || {
8374                    serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
8375                        "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
8376                        "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
8377                        "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
8378                        "has_coverage":false,"submodules":{}})
8379                },
8380                build_test_scope_entry,
8381            ),
8382        );
8383        let all_roots: Vec<String> = {
8384            let reg = state.registry.lock().await;
8385            let mut seen = std::collections::BTreeSet::new();
8386            reg.entries
8387                .iter()
8388                .flat_map(|e| e.input_roots.iter().cloned())
8389                .filter(|r| seen.insert(r.clone()))
8390                .collect()
8391        };
8392        for root in &all_roots {
8393            let run_for_root: Option<AnalysisRun> = {
8394                let reg = state.registry.lock().await;
8395                let json_str = reg
8396                    .entries
8397                    .iter()
8398                    .find(|e| e.input_roots.iter().any(|r| r == root))
8399                    .and_then(|e| e.json_path.as_ref())
8400                    .and_then(|p| std::fs::read_to_string(p).ok());
8401                drop(reg);
8402                json_str
8403                    .as_deref()
8404                    .and_then(|s| serde_json::from_str(s).ok())
8405            };
8406            if let Some(ref run) = run_for_root {
8407                scope_map.insert(root.clone(), build_scope_entry_for_run(run));
8408            }
8409        }
8410        serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
8411    };
8412
8413    let html = format!(
8414        r#"<!doctype html>
8415<html lang="en">
8416<head>
8417  <meta charset="utf-8" />
8418  <meta name="viewport" content="width=device-width, initial-scale=1" />
8419  <title>OxideSLOC | Test Metrics</title>
8420  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8421  <style nonce="{nonce}">
8422    :root {{
8423      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
8424      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8425      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
8426      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8427      --info-bg:#eef3ff; --info-text:#4467d8;
8428    }}
8429    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
8430    *{{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;}}
8431    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8432    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
8433    .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;}}
8434    @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));}}}}
8435    .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);}}
8436    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
8437    .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));}}
8438    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8439    .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;}}
8440    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
8441    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
8442    @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; }} }}
8443    .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;}}
8444    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8445    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8446    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8447    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
8448    .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;}}
8449    .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;}}
8450    .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;}}
8451    .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;}}
8452    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8453    .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);}}
8454    .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;}}
8455    .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;}}
8456    .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;}}
8457    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8458    .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;}}
8459    .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);}}
8460    .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;}}
8461    .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;}}
8462    .tz-select:focus{{border-color:var(--oxide);}}
8463    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8464    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
8465    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
8466    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
8467    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
8468    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
8469    .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;}}
8470    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
8471    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
8472    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
8473    .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;}}
8474    .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;}}
8475    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
8476    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
8477    .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);}}
8478    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
8479    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
8480    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
8481    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
8482    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
8483    .chart-canvas-wrap{{position:relative;height:280px;}}
8484    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8485    .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;}}
8486    .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;}}
8487    .data-table tr:last-child td{{border-bottom:none;}}
8488    .data-table tbody tr:hover td{{background:var(--surface-2);}}
8489    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
8490    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
8491    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
8492    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
8493    .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;}}
8494    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
8495    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
8496    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
8497    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
8498    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
8499    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
8500    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
8501    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
8502    .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;}}
8503    .chart-select:focus{{border-color:var(--accent);}}
8504    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
8505    .trend-canvas-wrap{{position:relative;height:260px;}}
8506    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
8507    .site-footer a{{color:var(--muted);}}
8508    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
8509    .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;}}
8510    .btn:hover{{background:var(--surface-2);}}
8511    .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;}}
8512    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8513    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
8514    .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;}}
8515    .scope-sel:focus{{border-color:var(--accent);}}
8516    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
8517    .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;}}
8518    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
8519    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8520    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
8521    .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;}}
8522    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
8523    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
8524    .watched-chip-rm:hover{{color:var(--oxide);}}
8525    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
8526    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
8527    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
8528    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
8529    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
8530    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
8531    .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;}}
8532    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
8533    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
8534    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
8535    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
8536    .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;}}
8537    .cov-file-search:focus{{border-color:var(--accent);}}
8538    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
8539    .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;}}
8540    body.dark-theme .cov-file-search{{background:var(--surface);}}
8541  </style>
8542</head>
8543<body>
8544  <div class="background-watermarks" aria-hidden="true">
8545    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8546    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8547    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8548    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8549    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8550    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8551  </div>
8552  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8553  <div class="top-nav">
8554    <div class="top-nav-inner">
8555      <a class="brand" href="/">
8556        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8557        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
8558      </a>
8559      <div class="nav-right">
8560        <a class="nav-pill" href="/">Home</a>
8561        <div class="nav-dropdown">
8562          <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>
8563          <div class="nav-dropdown-menu">
8564            <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>
8565          </div>
8566        </div>
8567        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8568        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
8569        <div class="nav-dropdown">
8570          <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>
8571          <div class="nav-dropdown-menu">
8572            <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>
8573          </div>
8574        </div>
8575        <div class="server-status-wrap" id="server-status-wrap">
8576          <div class="nav-pill server-online-pill" id="server-status-pill">
8577            <span class="status-dot" id="status-dot"></span>
8578            <span id="server-status-label">Server</span>
8579            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
8580          </div>
8581          <div class="server-status-tip">
8582            OxideSLOC is running — accessible on your network.
8583            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
8584          </div>
8585        </div>
8586        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
8587          <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>
8588        </button>
8589        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8590          <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>
8591          <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>
8592        </button>
8593      </div>
8594    </div>
8595  </div>
8596
8597  <div class="page">
8598    {watched_dirs_html}
8599    <div class="scope-bar">
8600      <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>
8601      <span class="scope-label">Scope</span>
8602      <div class="scope-sel-wrap">
8603        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
8604        <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);">
8605          <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>
8606          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
8607        </div>
8608      </div>
8609    </div>
8610    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8611      <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>
8612      <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>
8613      <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>
8614      <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>
8615    </div>
8616    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8617      <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>
8618      <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>
8619      <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>
8620      <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>
8621    </div>
8622
8623    <div class="panel">
8624      <h1>Test Metrics</h1>
8625      <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>
8626
8627      <div class="chart-row">
8628        <div class="chart-box">
8629          <div class="chart-box-title">Test Definitions by Language</div>
8630          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
8631        </div>
8632        <div class="chart-box">
8633          <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
8634          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
8635        </div>
8636      </div>
8637
8638      <div class="section-header">Language Breakdown</div>
8639      {cov_no_data_notice}
8640      <div style="overflow-x:auto;">
8641        <table class="data-table" id="lang-table">
8642          <thead><tr>
8643            <th>Language</th>
8644            <th class="num">Test Fns</th>
8645            <th class="num">Assertions</th>
8646            <th class="num">Suites</th>
8647            <th class="num">Code Lines</th>
8648            <th class="num">Files</th>
8649            <th class="num">Density / 1K</th>
8650            <th>Relative Density</th>
8651          </tr></thead>
8652          <tbody id="lang-tbody"></tbody>
8653        </table>
8654      </div>
8655    </div>
8656
8657    <div class="panel" id="cov-panel" style="display:none;">
8658      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
8659      <div class="cov-gauge-row" id="cov-gauges">
8660        <div class="cov-gauge-card">
8661          <div class="cov-gauge-label">Line Coverage</div>
8662          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
8663          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
8664          <div class="cov-gauge-sub">Lines hit / instrumented</div>
8665        </div>
8666        <div class="cov-gauge-card">
8667          <div class="cov-gauge-label">Function Coverage</div>
8668          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
8669          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
8670          <div class="cov-gauge-sub">Functions hit / found</div>
8671        </div>
8672        <div class="cov-gauge-card">
8673          <div class="cov-gauge-label">Branch Coverage</div>
8674          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
8675          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
8676          <div class="cov-gauge-sub">Branches hit / found</div>
8677        </div>
8678      </div>
8679      <div class="chart-row">
8680        <div class="chart-box">
8681          <div class="chart-box-title">Line Coverage % by Language</div>
8682          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
8683        </div>
8684        <div class="chart-box">
8685          <div class="chart-box-title">Coverage Tier Distribution</div>
8686          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
8687        </div>
8688      </div>
8689
8690      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
8691      <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>
8692      <div class="cov-file-toolbar">
8693        <div class="cov-filter-tabs" id="cov-filter-tabs">
8694          <button class="cov-tab active" data-tier="all">All</button>
8695          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
8696          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
8697          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
8698          <button class="cov-tab" data-tier="high">High (≥80%)</button>
8699        </div>
8700        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
8701      </div>
8702      <div style="overflow-x:auto;">
8703        <table class="data-table" id="cov-file-table">
8704          <thead><tr>
8705            <th>File</th>
8706            <th>Lang</th>
8707            <th class="num">Line %</th>
8708            <th class="num">Lines Hit / Found</th>
8709            <th class="num">Fn %</th>
8710            <th class="num">Fns Hit / Found</th>
8711          </tr></thead>
8712          <tbody id="cov-file-tbody"></tbody>
8713        </table>
8714      </div>
8715      <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>
8716      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
8717    </div>
8718
8719    <div class="panel">
8720      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
8721      <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
8722      <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
8723      <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
8724    </div>
8725  </div>
8726
8727  <footer class="site-footer">
8728    local code analysis - metrics, history and reports
8729    &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>
8730    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8731    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8732    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8733    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
8734  </footer>
8735
8736  <script nonce="{nonce}">
8737  (function() {{
8738    // Theme
8739    var b = document.body;
8740    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
8741    var tgl = document.getElementById('theme-toggle');
8742    if (tgl) tgl.addEventListener('click', function() {{
8743      var d = b.classList.toggle('dark-theme');
8744      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
8745    }});
8746
8747    // Watermarks
8748    (function() {{
8749      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8750      if (!wms.length) return;
8751      var placed = [];
8752      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;}}
8753      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];}}
8754      var half=Math.floor(wms.length/2);
8755      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;}});
8756    }})();
8757
8758    // Code particles
8759    (function() {{
8760      var container = document.getElementById('code-particles');
8761      if (!container) return;
8762      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
8763      for (var i = 0; i < 36; i++) {{
8764        (function(idx) {{
8765          var el = document.createElement('span');
8766          el.className = 'code-particle';
8767          el.textContent = snippets[idx % snippets.length];
8768          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
8769          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
8770          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
8771          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';
8772          container.appendChild(el);
8773        }})(i);
8774      }}
8775    }})();
8776
8777    // Settings modal
8778    (function() {{
8779      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'}}];
8780      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);}});}}
8781      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
8782      var btn=document.getElementById('settings-btn');if(!btn)return;
8783      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
8784      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>';
8785      document.body.appendChild(m);
8786      var g=document.getElementById('scheme-grid');
8787      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);}});
8788      var cl=document.getElementById('settings-close');
8789      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');}});
8790      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
8791      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
8792    }})();
8793
8794    // Watched folder picker
8795    (function() {{
8796      var btn = document.getElementById('add-watched-btn');
8797      if (!btn) return;
8798      btn.addEventListener('click', function() {{
8799        fetch('/pick-directory?kind=reports')
8800          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
8801          .then(function(data) {{
8802            if (!data.cancelled && data.selected_path) {{
8803              var form = document.createElement('form');
8804              form.method = 'POST';
8805              form.action = '/watched-dirs/add';
8806              var ri = document.createElement('input');
8807              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
8808              var fi = document.createElement('input');
8809              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
8810              form.appendChild(ri); form.appendChild(fi);
8811              document.body.appendChild(form);
8812              form.submit();
8813            }}
8814          }})
8815          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
8816      }});
8817    }})();
8818  }})();
8819  </script>
8820
8821  <script src="/static/chart.js" nonce="{nonce}"></script>
8822  <script nonce="{nonce}">
8823  (function() {{
8824    var SCOPE_DATA = {scope_data_json};
8825    var currentRoot = '__all__';
8826    var currentSub  = '';
8827    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
8828    var ALL_CHARTS = [];
8829
8830    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();}}
8831    function fmtFull(n){{return Number(n).toLocaleString();}}
8832    function isDark(){{return document.body.classList.contains('dark-theme');}}
8833    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
8834    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
8835    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
8836
8837    function getDataset() {{
8838      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
8839      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
8840      return r;
8841    }}
8842    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
8843
8844    function renderTestCharts(D) {{
8845      testsChart = destroyChart(testsChart);
8846      densityChart = destroyChart(densityChart);
8847      if (!D || !D.length) return;
8848      var top15 = D.slice(0, 15);
8849      var canvas1 = document.getElementById('canvas-tests');
8850      if (canvas1) {{
8851        testsChart = new Chart(canvas1, {{
8852          type: 'bar',
8853          data: {{
8854            labels: top15.map(function(d){{ return d.lang; }}),
8855            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
8856          }},
8857          options: {{
8858            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8859            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
8860            scales: {{
8861              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
8862              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8863            }}
8864          }}
8865        }});
8866        ALL_CHARTS.push(testsChart);
8867      }}
8868      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
8869      var canvas2 = document.getElementById('canvas-density');
8870      if (canvas2) {{
8871        densityChart = new Chart(canvas2, {{
8872          type: 'bar',
8873          data: {{
8874            labels: topD.map(function(d){{ return d.lang; }}),
8875            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 }}]
8876          }},
8877          options: {{
8878            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8879            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
8880            scales: {{
8881              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
8882              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8883            }}
8884          }}
8885        }});
8886        ALL_CHARTS.push(densityChart);
8887      }}
8888    }}
8889
8890    function renderCovCharts(covD, tiers) {{
8891      covChart = destroyChart(covChart);
8892      tierChart = destroyChart(tierChart);
8893      var covCanvas = document.getElementById('canvas-cov');
8894      if (covCanvas && covD && covD.length) {{
8895        covChart = new Chart(covCanvas, {{
8896          type: 'bar',
8897          data: {{
8898            labels: covD.map(function(d){{ return d.lang; }}),
8899            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 }}]
8900          }},
8901          options: {{
8902            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8903            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
8904            scales: {{
8905              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
8906              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8907            }}
8908          }}
8909        }});
8910        ALL_CHARTS.push(covChart);
8911      }}
8912      var tierCanvas = document.getElementById('canvas-cov-tiers');
8913      if (tierCanvas && tiers) {{
8914        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
8915        tierChart = new Chart(tierCanvas, {{
8916          type: 'doughnut',
8917          data: {{
8918            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
8919            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
8920          }},
8921          options: {{
8922            responsive: true, maintainAspectRatio: false, cutout: '62%',
8923            plugins: {{
8924              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
8925              tooltip: {{ callbacks: {{ label: function(ctx) {{
8926                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
8927                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
8928              }} }} }}
8929            }}
8930          }}
8931        }});
8932        ALL_CHARTS.push(tierChart);
8933      }}
8934    }}
8935
8936    function buildLangTable(D) {{
8937      var tbody = document.getElementById('lang-tbody');
8938      if (!tbody) return;
8939      if (!D || !D.length) {{
8940        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>';
8941        return;
8942      }}
8943      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
8944      tbody.innerHTML = D.map(function(d) {{
8945        var barW = Math.round(d.density / maxDensity * 120);
8946        return '<tr>' +
8947          '<td><strong>' + d.lang + '</strong></td>' +
8948          '<td class="num">' + fmt(d.tests) + '</td>' +
8949          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
8950          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
8951          '<td class="num">' + fmt(d.code) + '</td>' +
8952          '<td class="num">' + fmt(d.files) + '</td>' +
8953          '<td class="num">' + d.density.toFixed(2) + '</td>' +
8954          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
8955          '</tr>';
8956      }}).join('');
8957    }}
8958
8959    var covFileData = [];
8960    var covFileTier = 'all';
8961    var covFileSearch = '';
8962
8963    function pctBadge(pct) {{
8964      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
8965      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
8966      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
8967    }}
8968
8969    function buildCovFileTable() {{
8970      var tbody = document.getElementById('cov-file-tbody');
8971      var empty = document.getElementById('cov-file-empty');
8972      var count = document.getElementById('cov-file-count');
8973      if (!tbody) return;
8974      var srch = covFileSearch.toLowerCase();
8975      var filtered = covFileData.filter(function(f) {{
8976        if (covFileTier === 'zero' && f.line_pct > 0) return false;
8977        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
8978        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
8979        if (covFileTier === 'high' && f.line_pct < 80) return false;
8980        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
8981        return true;
8982      }});
8983      if (!filtered.length) {{
8984        tbody.innerHTML = '';
8985        if (empty) empty.style.display = '';
8986        if (count) count.textContent = '';
8987        return;
8988      }}
8989      if (empty) empty.style.display = 'none';
8990      var shown = Math.min(filtered.length, 500);
8991      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
8992      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
8993        var fnCol = f.fn_pct < 0
8994          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
8995          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
8996        return '<tr>' +
8997          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
8998          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
8999          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
9000          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
9001          fnCol +
9002          '</tr>';
9003      }}).join('');
9004    }}
9005
9006    (function() {{
9007      var tabs = document.getElementById('cov-filter-tabs');
9008      if (tabs) {{
9009        tabs.addEventListener('click', function(e) {{
9010          var btn = e.target.closest('.cov-tab');
9011          if (!btn) return;
9012          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
9013          btn.classList.add('active');
9014          covFileTier = btn.getAttribute('data-tier');
9015          buildCovFileTable();
9016        }});
9017      }}
9018      var srch = document.getElementById('cov-file-search');
9019      if (srch) {{
9020        srch.addEventListener('input', function() {{
9021          covFileSearch = this.value;
9022          buildCovFileTable();
9023        }});
9024      }}
9025    }})();
9026
9027    function updateCovGauges(t) {{
9028      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
9029      var el;
9030      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
9031      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
9032      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
9033      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
9034      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
9035      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
9036    }}
9037
9038    function applyScope() {{
9039      var d = getDataset();
9040      var t = d.totals;
9041      var el;
9042      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
9043      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
9044      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
9045      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
9046      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
9047      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
9048      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
9049      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
9050      renderTestCharts(d.lang_tests);
9051      buildLangTable(d.lang_tests);
9052      var covPanel = document.getElementById('cov-panel');
9053      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
9054      if (d.has_coverage) {{
9055        renderCovCharts(d.cov, d.cov_tiers);
9056        updateCovGauges(t);
9057        covFileData = d.file_cov || [];
9058        covFileTier = 'all';
9059        covFileSearch = '';
9060        var tabs = document.getElementById('cov-filter-tabs');
9061        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
9062        var srch = document.getElementById('cov-file-search');
9063        if (srch) srch.value = '';
9064        buildCovFileTable();
9065      }}
9066      loadTrend();
9067    }}
9068
9069    // Populate scope-root-sel from SCOPE_DATA keys
9070    (function() {{
9071      var sel = document.getElementById('scope-root-sel');
9072      if (!sel) return;
9073      Object.keys(SCOPE_DATA).forEach(function(k) {{
9074        if (k === '__all__') return;
9075        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
9076      }});
9077    }})();
9078
9079    document.getElementById('scope-root-sel').addEventListener('change', function() {{
9080      currentRoot = this.value;
9081      currentSub = '';
9082      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
9083      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
9084      var subWrap = document.getElementById('scope-sub-wrap');
9085      var subSel  = document.getElementById('scope-sub-sel');
9086      subSel.innerHTML = '<option value="">Entire project</option>';
9087      if (subNames.length) {{
9088        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
9089        subWrap.style.display = 'flex';
9090      }} else {{
9091        subWrap.style.display = 'none';
9092      }}
9093      applyScope();
9094    }});
9095
9096    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
9097      currentSub = this.value;
9098      applyScope();
9099    }});
9100
9101    function buildTrend(data) {{
9102      var trendCanvas = document.getElementById('canvas-trend');
9103      var trendEmpty  = document.getElementById('trend-empty');
9104      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
9105      pts = pts.slice().reverse();
9106      if (!pts.length) {{
9107        if (trendCanvas) trendCanvas.style.display = 'none';
9108        if (trendEmpty) trendEmpty.style.display = '';
9109        return;
9110      }}
9111      if (trendCanvas) trendCanvas.style.display = '';
9112      if (trendEmpty) trendEmpty.style.display = 'none';
9113      trendChart = destroyChart(trendChart);
9114      if (!trendCanvas) return;
9115      trendChart = new Chart(trendCanvas, {{
9116        type: 'line',
9117        data: {{
9118          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
9119          datasets: [{{
9120            label: 'Test Definitions',
9121            data: pts.map(function(d){{ return d.test_count; }}),
9122            borderColor: '#C45C10',
9123            backgroundColor: 'rgba(196,92,16,0.10)',
9124            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
9125            pointRadius: 5, fill: true, tension: 0.3
9126          }}]
9127        }},
9128        options: {{
9129          responsive: true, maintainAspectRatio: false,
9130          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
9131          scales: {{
9132            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
9133            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
9134          }}
9135        }}
9136      }});
9137      ALL_CHARTS.push(trendChart);
9138    }}
9139
9140    function loadTrend() {{
9141      var url = '/api/metrics/history?limit=100';
9142      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
9143      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
9144        buildTrend(data);
9145      }}).catch(function(){{
9146        var trendEmpty = document.getElementById('trend-empty');
9147        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
9148      }});
9149    }}
9150
9151    // Re-render charts on theme toggle
9152    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
9153      setTimeout(function() {{
9154        ALL_CHARTS.forEach(function(c) {{
9155          if (c && c.options && c.options.scales) {{
9156            Object.values(c.options.scales).forEach(function(ax) {{
9157              if (ax.grid) ax.grid.color = clr();
9158              if (ax.ticks) ax.ticks.color = txtClr();
9159            }});
9160            c.update();
9161          }}
9162        }});
9163      }}, 80);
9164    }});
9165
9166    applyScope();
9167  }})();
9168  </script>
9169  <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>
9170</body>
9171</html>"#,
9172    );
9173    Html(html).into_response()
9174}
9175
9176// ── Embeddable widget ─────────────────────────────────────────────────────────
9177// Protected. Returns a self-contained HTML page suitable for iframing inside
9178// Jenkins build summaries, Confluence iframe macros, or Jira panels.
9179//
9180// GET /embed/summary?run_id=<uuid>&theme=dark
9181
9182#[derive(Deserialize)]
9183struct EmbedQuery {
9184    run_id: Option<String>,
9185    theme: Option<String>,
9186}
9187
9188async fn embed_handler(
9189    State(state): State<AppState>,
9190    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9191    Query(query): Query<EmbedQuery>,
9192) -> Response {
9193    let entry = {
9194        let reg = state.registry.lock().await;
9195        query.run_id.as_ref().map_or_else(
9196            || reg.entries.first().cloned(),
9197            |id| reg.find_by_run_id(id).cloned(),
9198        )
9199    };
9200
9201    let Some(entry) = entry else {
9202        return Html(
9203            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
9204                .to_string(),
9205        )
9206        .into_response();
9207    };
9208
9209    let dark = query.theme.as_deref() == Some("dark");
9210    let languages: Vec<(String, u64, u64)> = entry
9211        .json_path
9212        .as_ref()
9213        .and_then(|p| read_json(p).ok())
9214        .map(|run| {
9215            run.totals_by_language
9216                .iter()
9217                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
9218                .collect()
9219        })
9220        .unwrap_or_default();
9221
9222    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
9223}
9224
9225fn render_embed_widget(
9226    entry: &RegistryEntry,
9227    languages: &[(String, u64, u64)],
9228    dark: bool,
9229    csp_nonce: &str,
9230) -> String {
9231    let s = &entry.summary;
9232    let total = s.code_lines + s.comment_lines + s.blank_lines;
9233    let code_pct = s
9234        .code_lines
9235        .checked_mul(100)
9236        .and_then(|n| n.checked_div(total))
9237        .unwrap_or(0);
9238
9239    let (bg, fg, surface, muted, border) = if dark {
9240        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
9241    } else {
9242        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
9243    };
9244
9245    let mut lang_rows = String::new();
9246    for (name, files, code) in languages {
9247        write!(
9248            lang_rows,
9249            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
9250            escape_html(name),
9251            format_number(*files),
9252            format_number(*code),
9253        )
9254        .ok();
9255    }
9256
9257    let lang_table = if lang_rows.is_empty() {
9258        String::new()
9259    } else {
9260        format!(
9261            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
9262        )
9263    };
9264
9265    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
9266    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
9267    let project_esc = escape_html(&entry.project_label);
9268    let code_lines = format_number(s.code_lines);
9269    let comment_lines = format_number(s.comment_lines);
9270    let files = format_number(s.files_analyzed);
9271    let code_raw = s.code_lines;
9272    let comment_raw = s.comment_lines;
9273    let blank_raw = s.blank_lines;
9274
9275    format!(
9276        r#"<!doctype html>
9277<html lang="en">
9278<head>
9279  <meta charset="utf-8">
9280  <meta name="viewport" content="width=device-width,initial-scale=1">
9281  <title>OxideSLOC &mdash; {project_esc}</title>
9282  <script src="/static/chart.js"></script>
9283  <style nonce="{csp_nonce}">
9284    *{{box-sizing:border-box;margin:0;padding:0}}
9285    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
9286    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
9287    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
9288    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
9289    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
9290    .card .v{{font-size:18px;font-weight:700}}
9291    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
9292    .row{{display:flex;gap:12px;align-items:flex-start}}
9293    .pie{{width:120px;height:120px;flex-shrink:0}}
9294    .lt{{border-collapse:collapse;width:100%;flex:1}}
9295    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
9296    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
9297    .n{{text-align:right}}
9298    .footer{{margin-top:10px;color:{muted};font-size:10px}}
9299  </style>
9300</head>
9301<body>
9302  <h2>{project_esc}</h2>
9303  <div class="sub">{timestamp} &middot; run {run_short}</div>
9304  <div class="cards">
9305    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
9306    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
9307    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
9308    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
9309  </div>
9310  <div class="row">
9311    <canvas class="pie" id="c"></canvas>
9312    {lang_table}
9313  </div>
9314  <div class="footer">oxide-sloc</div>
9315  <script nonce="{csp_nonce}">
9316    new Chart(document.getElementById('c'),{{
9317      type:'doughnut',
9318      data:{{
9319        labels:['Code','Comments','Blank'],
9320        datasets:[{{
9321          data:[{code_raw},{comment_raw},{blank_raw}],
9322          backgroundColor:['#4a78ee','#b35428','#aaa'],
9323          borderWidth:0
9324        }}]
9325      }},
9326      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
9327    }});
9328  </script>
9329</body>
9330</html>"#
9331    )
9332}
9333
9334#[allow(clippy::too_many_arguments)]
9335fn persist_run_artifacts(
9336    run: &sloc_core::AnalysisRun,
9337    report_html: &str,
9338    run_dir: &Path,
9339    generate_json: bool,
9340    generate_html: bool,
9341    generate_pdf: bool,
9342    report_title: &str,
9343    file_stem: &str,
9344    result_context: RunResultContext,
9345) -> Result<(RunArtifacts, PendingPdf)> {
9346    fs::create_dir_all(run_dir)
9347        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
9348
9349    let mut html_path = None;
9350    let mut pdf_path = None;
9351    let mut json_path = None;
9352    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
9353
9354    if generate_html {
9355        let path = run_dir.join(format!("report_{file_stem}.html"));
9356        fs::write(&path, report_html)
9357            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
9358        html_path = Some(path);
9359    }
9360
9361    if generate_json {
9362        let path = run_dir.join(format!("result_{file_stem}.json"));
9363        let json = serde_json::to_string_pretty(run)
9364            .context("failed to serialize analysis run to JSON")?;
9365        fs::write(&path, json)
9366            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
9367        json_path = Some(path);
9368    }
9369
9370    if generate_pdf {
9371        let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
9372
9373        // Attempt pure-Rust native PDF (zero external dependencies).
9374        // Falls back to the HTML→browser background task on failure.
9375        match write_pdf_from_run(run, &pdf_dest) {
9376            Ok(()) => {
9377                eprintln!(
9378                    "[oxide-sloc][pdf] native PDF written to {}",
9379                    pdf_dest.display()
9380                );
9381                pdf_path = Some(pdf_dest);
9382                // pending_pdf stays None — no background browser task needed.
9383            }
9384            Err(native_err) => {
9385                eprintln!(
9386                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), \
9387                     scheduling HTML→browser fallback"
9388                );
9389                let source_html_path = if let Some(existing) = html_path.as_ref() {
9390                    existing.clone()
9391                } else {
9392                    let temp_html = run_dir.join("_report_rendered.html");
9393                    fs::write(&temp_html, report_html).with_context(|| {
9394                        format!(
9395                            "failed to write temporary HTML report to {}",
9396                            temp_html.display()
9397                        )
9398                    })?;
9399                    temp_html
9400                };
9401                let cleanup_src = !generate_html;
9402                pdf_path = Some(pdf_dest.clone());
9403                pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
9404            }
9405        }
9406    }
9407
9408    // CSV and XLSX are always generated (like JSON) — no extra flag required.
9409    let csv_path = {
9410        let path = run_dir.join(format!("report_{file_stem}.csv"));
9411        if let Err(e) = sloc_report::write_csv(run, &path) {
9412            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
9413            None
9414        } else {
9415            Some(path)
9416        }
9417    };
9418
9419    let xlsx_path = {
9420        let path = run_dir.join(format!("report_{file_stem}.xlsx"));
9421        if let Err(e) = sloc_report::write_xlsx(run, &path) {
9422            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
9423            None
9424        } else {
9425            Some(path)
9426        }
9427    };
9428
9429    let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
9430
9431    Ok((
9432        RunArtifacts {
9433            output_dir: run_dir.to_path_buf(),
9434            html_path,
9435            pdf_path,
9436            json_path,
9437            csv_path,
9438            xlsx_path,
9439            scan_config_path,
9440            report_title: report_title.to_string(),
9441            result_context,
9442        },
9443        pending_pdf,
9444    ))
9445}
9446
9447/// Find a scan-config JSON file in `dir`, checking both the legacy fixed name and
9448/// the current `scan-config_<stem>.json` pattern for backwards compatibility.
9449fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
9450    let exact = dir.join("scan-config.json");
9451    if exact.exists() {
9452        return Some(exact);
9453    }
9454    fs::read_dir(dir).ok().and_then(|entries| {
9455        entries
9456            .filter_map(std::result::Result::ok)
9457            .find(|e| {
9458                let name = e.file_name();
9459                let name = name.to_string_lossy();
9460                name.starts_with("scan-config") && name.ends_with(".json")
9461            })
9462            .map(|e| e.path())
9463    })
9464}
9465
9466// ── Config export / import ────────────────────────────────────────────────────
9467
9468async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
9469    let toml_str = match toml::to_string_pretty(&state.base_config) {
9470        Ok(s) => s,
9471        Err(e) => {
9472            return (
9473                StatusCode::INTERNAL_SERVER_ERROR,
9474                format!("serialization error: {e}"),
9475            )
9476                .into_response();
9477        }
9478    };
9479    (
9480        [
9481            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
9482            (
9483                header::CONTENT_DISPOSITION,
9484                "attachment; filename=\".oxide-sloc.toml\"",
9485            ),
9486        ],
9487        toml_str,
9488    )
9489        .into_response()
9490}
9491
9492#[derive(Serialize)]
9493struct OkResponse {
9494    ok: bool,
9495}
9496
9497#[derive(Serialize)]
9498struct SaveProfileResponse {
9499    ok: bool,
9500    id: String,
9501}
9502
9503#[derive(Serialize)]
9504struct ProfileListResponse {
9505    profiles: Vec<ScanProfile>,
9506}
9507
9508#[derive(Serialize)]
9509struct ImportConfigResponse {
9510    ok: bool,
9511    config: sloc_config::AppConfig,
9512}
9513
9514#[derive(Deserialize)]
9515struct ImportConfigBody {
9516    toml: String,
9517}
9518
9519async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
9520    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
9521        Ok(config) => {
9522            if let Err(e) = config.validate() {
9523                return error::unprocessable_entity(&e.to_string());
9524            }
9525            Json(ImportConfigResponse { ok: true, config }).into_response()
9526        }
9527        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
9528    }
9529}
9530
9531// ── Scan profiles API ─────────────────────────────────────────────────────────
9532
9533async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
9534    let store = state.scan_profiles.lock().await;
9535    Json(ProfileListResponse {
9536        profiles: store.profiles.clone(),
9537    })
9538}
9539
9540#[derive(Deserialize)]
9541struct SaveScanProfileBody {
9542    name: String,
9543    params: serde_json::Value,
9544}
9545
9546async fn api_save_scan_profile(
9547    State(state): State<AppState>,
9548    Json(body): Json<SaveScanProfileBody>,
9549) -> impl IntoResponse {
9550    if body.name.trim().is_empty() {
9551        return error::bad_request("name must not be empty");
9552    }
9553
9554    let id = uuid::Uuid::new_v4().to_string();
9555    let profile = ScanProfile {
9556        id: id.clone(),
9557        name: body.name.trim().to_string(),
9558        created_at: chrono::Utc::now().to_rfc3339(),
9559        params: body.params,
9560    };
9561
9562    let mut store = state.scan_profiles.lock().await;
9563    store.profiles.push(profile);
9564    if let Err(e) = store.save(&state.scan_profiles_path) {
9565        tracing::warn!("failed to persist scan profiles: {e}");
9566    }
9567    drop(store);
9568
9569    (
9570        StatusCode::CREATED,
9571        Json(SaveProfileResponse { ok: true, id }),
9572    )
9573        .into_response()
9574}
9575
9576async fn api_delete_scan_profile(
9577    State(state): State<AppState>,
9578    AxumPath(id): AxumPath<String>,
9579) -> impl IntoResponse {
9580    let mut store = state.scan_profiles.lock().await;
9581    let before = store.profiles.len();
9582    store.profiles.retain(|p| p.id != id);
9583    if store.profiles.len() == before {
9584        drop(store);
9585        return error::not_found("profile not found");
9586    }
9587    if let Err(e) = store.save(&state.scan_profiles_path) {
9588        tracing::warn!("failed to persist scan profiles: {e}");
9589    }
9590    drop(store);
9591    Json(OkResponse { ok: true }).into_response()
9592}
9593
9594fn resolve_output_root(raw: Option<&str>) -> PathBuf {
9595    let value = raw.unwrap_or("out/web").trim();
9596    let path = if value.is_empty() {
9597        PathBuf::from("out/web")
9598    } else {
9599        PathBuf::from(value)
9600    };
9601
9602    if path.is_absolute() {
9603        path
9604    } else {
9605        workspace_root().join(path)
9606    }
9607}
9608
9609/// Derive the directory that holds remote-repo clones from the output root.
9610fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
9611    std::env::var("SLOC_GIT_CLONES_DIR")
9612        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
9613}
9614
9615/// Build a deterministic filesystem path for a cloned remote repository.
9616/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
9617pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
9618    let safe: String = repo_url
9619        .chars()
9620        .map(|c| {
9621            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
9622                c
9623            } else {
9624                '_'
9625            }
9626        })
9627        .take(80)
9628        .collect();
9629    clones_dir.join(safe)
9630}
9631
9632/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
9633/// Runs synchronously — call from `tokio::task::spawn_blocking`.
9634pub(crate) fn scan_path_to_artifacts(
9635    scan_path: &Path,
9636    base_config: &AppConfig,
9637    label: &str,
9638) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
9639    let mut config = base_config.clone();
9640    config.discovery.root_paths = vec![scan_path.to_path_buf()];
9641    label.clone_into(&mut config.reporting.report_title);
9642    let run = analyze(&config, "git", None)?;
9643    let html = render_html(&run)?;
9644    let run_id = run.tool.run_id.clone();
9645    let project_label = sanitize_project_label(label);
9646    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9647    let file_stem = {
9648        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
9649        if commit.is_empty() {
9650            project_label
9651        } else {
9652            format!("{project_label}_{commit}")
9653        }
9654    };
9655    let (artifacts, _pending_pdf) = persist_run_artifacts(
9656        &run,
9657        &html,
9658        &output_dir,
9659        true,
9660        true,
9661        false,
9662        label,
9663        &file_stem,
9664        RunResultContext::default(),
9665    )?;
9666    Ok((run_id, artifacts, run))
9667}
9668
9669/// Re-spawn background poll tasks for any polling schedules saved to disk.
9670async fn restart_poll_schedules(state: &AppState) {
9671    let store = state.schedules.lock().await;
9672    let poll_schedules: Vec<_> = store
9673        .schedules
9674        .iter()
9675        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
9676        .cloned()
9677        .collect();
9678    drop(store);
9679    for schedule in poll_schedules {
9680        let interval = schedule.interval_secs.unwrap_or(300);
9681        let st = state.clone();
9682        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
9683    }
9684}
9685
9686fn split_patterns(raw: Option<&str>) -> Vec<String> {
9687    raw.unwrap_or("")
9688        .lines()
9689        .flat_map(|line| line.split(','))
9690        .map(str::trim)
9691        .filter(|part| !part.is_empty())
9692        .map(ToOwned::to_owned)
9693        .collect()
9694}
9695
9696fn build_sub_run(
9697    parent: &AnalysisRun,
9698    sub: &sloc_core::SubmoduleSummary,
9699    parent_path: &str,
9700) -> AnalysisRun {
9701    let sub_files: Vec<_> = parent
9702        .per_file_records
9703        .iter()
9704        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9705        .cloned()
9706        .collect();
9707    let mut config = parent.effective_configuration.clone();
9708    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
9709    AnalysisRun {
9710        tool: parent.tool.clone(),
9711        environment: parent.environment.clone(),
9712        effective_configuration: config,
9713        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
9714        summary_totals: SummaryTotals {
9715            files_considered: sub.files_analyzed,
9716            files_analyzed: sub.files_analyzed,
9717            files_skipped: 0,
9718            total_physical_lines: sub.total_physical_lines,
9719            code_lines: sub.code_lines,
9720            comment_lines: sub.comment_lines,
9721            blank_lines: sub.blank_lines,
9722            mixed_lines_separate: 0,
9723            functions: 0,
9724            classes: 0,
9725            variables: 0,
9726            imports: 0,
9727            test_count: 0,
9728            test_assertion_count: 0,
9729            test_suite_count: 0,
9730            coverage_lines_found: 0,
9731            coverage_lines_hit: 0,
9732            coverage_functions_found: 0,
9733            coverage_functions_hit: 0,
9734            coverage_branches_found: 0,
9735            coverage_branches_hit: 0,
9736        },
9737        totals_by_language: sub.language_summaries.clone(),
9738        per_file_records: sub_files,
9739        skipped_file_records: vec![],
9740        warnings: vec![],
9741        submodule_summaries: vec![],
9742        git_commit_short: parent.git_commit_short.clone(),
9743        git_commit_long: parent.git_commit_long.clone(),
9744        git_branch: parent.git_branch.clone(),
9745        git_commit_author: parent.git_commit_author.clone(),
9746        git_commit_date: parent.git_commit_date.clone(),
9747        git_tags: parent.git_tags.clone(),
9748        git_nearest_tag: parent.git_nearest_tag.clone(),
9749    }
9750}
9751
9752pub(crate) fn sanitize_project_label(raw: &str) -> String {
9753    let candidate = Path::new(raw)
9754        .file_name()
9755        .and_then(|name| name.to_str())
9756        .unwrap_or("project");
9757
9758    let mut value = String::with_capacity(candidate.len());
9759    for ch in candidate.chars() {
9760        if ch.is_ascii_alphanumeric() {
9761            value.push(ch.to_ascii_lowercase());
9762        } else {
9763            value.push('-');
9764        }
9765    }
9766
9767    let compact = value.trim_matches('-').to_string();
9768    if compact.is_empty() {
9769        "project".to_string()
9770    } else {
9771        compact
9772    }
9773}
9774
9775/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
9776/// comparisons with non-canonicalized stored paths work correctly.
9777fn strip_unc_prefix(path: PathBuf) -> PathBuf {
9778    let s = path.to_string_lossy();
9779    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
9780        return PathBuf::from(format!(r"\\{rest}"));
9781    }
9782    if let Some(rest) = s.strip_prefix(r"\\?\") {
9783        return PathBuf::from(rest);
9784    }
9785    path
9786}
9787
9788fn display_path(path: &Path) -> String {
9789    let s = path.to_string_lossy();
9790    // Strip Windows extended-length prefix for display only; the underlying
9791    // PathBuf remains unchanged so file operations are unaffected.
9792    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
9793    // \\?\C:\path           →  C:\path          (local drive)
9794    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
9795        return format!(r"\\{rest}");
9796    }
9797    if let Some(rest) = s.strip_prefix(r"\\?\") {
9798        return rest.to_owned();
9799    }
9800    s.into_owned()
9801}
9802
9803fn sanitize_path_str(s: &str) -> String {
9804    // Forward-slash variants of the Windows extended-length prefix that appear
9805    // when paths stored as plain strings have been processed through some path
9806    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
9807    if let Some(rest) = s.strip_prefix("//?/UNC/") {
9808        return format!("//{rest}");
9809    }
9810    if let Some(rest) = s.strip_prefix("//?/") {
9811        return rest.to_owned();
9812    }
9813    display_path(Path::new(s))
9814}
9815
9816fn workspace_root() -> PathBuf {
9817    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
9818    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
9819        let p = PathBuf::from(root);
9820        if p.is_dir() {
9821            return p;
9822        }
9823    }
9824
9825    // Current working directory — works for `cargo run` from the project root
9826    // and for scripts/run.sh which cds there first.
9827    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
9828}
9829
9830/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
9831fn make_git_label(repo: &str, ref_name: &str) -> String {
9832    if repo.is_empty() || ref_name.is_empty() {
9833        return String::new();
9834    }
9835    let base = repo
9836        .trim_end_matches('/')
9837        .trim_end_matches(".git")
9838        .rsplit('/')
9839        .next()
9840        .unwrap_or("repo");
9841    let ref_safe: String = ref_name
9842        .chars()
9843        .map(|c| {
9844            if c.is_alphanumeric() || c == '-' || c == '.' {
9845                c
9846            } else {
9847                '_'
9848            }
9849        })
9850        .collect();
9851    format!("{base}_at_{ref_safe}_sloc")
9852}
9853
9854/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
9855fn desktop_dir() -> PathBuf {
9856    if let Ok(profile) = std::env::var("USERPROFILE") {
9857        let p = PathBuf::from(profile).join("Desktop");
9858        if p.exists() {
9859            return p;
9860        }
9861    }
9862    if let Ok(home) = std::env::var("HOME") {
9863        let p = PathBuf::from(home).join("Desktop");
9864        if p.exists() {
9865            return p;
9866        }
9867    }
9868    workspace_root().join("out").join("web")
9869}
9870
9871fn resolve_input_path(raw: &str) -> PathBuf {
9872    let trimmed = raw.trim();
9873    if trimmed.is_empty() {
9874        return workspace_root().join("samples").join("basic");
9875    }
9876
9877    let candidate = PathBuf::from(trimmed);
9878    let resolved = if candidate.is_absolute() {
9879        candidate
9880    } else {
9881        let rooted = workspace_root().join(&candidate);
9882        if rooted.exists() {
9883            rooted
9884        } else {
9885            workspace_root().join(candidate)
9886        }
9887    };
9888
9889    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
9890    // strip that prefix so stored paths and the displayed "Project path" are clean.
9891    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
9892    PathBuf::from(display_path(&canonical))
9893}
9894
9895fn dir_size_bytes(path: &Path) -> u64 {
9896    let mut total = 0u64;
9897    if let Ok(rd) = fs::read_dir(path) {
9898        for entry in rd.filter_map(Result::ok) {
9899            let p = entry.path();
9900            if p.is_file() {
9901                if let Ok(meta) = p.metadata() {
9902                    total += meta.len();
9903                }
9904            } else if p.is_dir() {
9905                total += dir_size_bytes(&p);
9906            }
9907        }
9908    }
9909    total
9910}
9911
9912#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
9913fn format_dir_size(bytes: u64) -> String {
9914    if bytes >= 1_073_741_824 {
9915        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
9916    } else if bytes >= 1_048_576 {
9917        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
9918    } else if bytes >= 1_024 {
9919        format!("{:.0} KB", bytes as f64 / 1_024.0)
9920    } else {
9921        format!("{bytes} B")
9922    }
9923}
9924
9925fn render_submodule_chips(
9926    root: &Path,
9927    submodules: &[(String, std::path::PathBuf)],
9928    out: &mut String,
9929) {
9930    use std::fmt::Write as _;
9931    let count = submodules.len();
9932    out.push_str(r#"<div class="submodule-preview-strip">"#);
9933    write!(
9934        out,
9935        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>"#,
9936        if count == 1 { "" } else { "s" }
9937    )
9938    .ok();
9939    out.push_str(r#"<div class="submodule-preview-chips">"#);
9940    for (sub_name, sub_rel_path) in submodules {
9941        let sub_abs = root.join(sub_rel_path);
9942        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
9943        let mut sub_stats = PreviewStats::default();
9944        let mut sub_rows: Vec<PreviewRow> = Vec::new();
9945        let mut sub_langs: Vec<&'static str> = Vec::new();
9946        let mut sub_budget = PreviewBudget {
9947            shown: 0,
9948            max_entries: 2000,
9949            max_depth: 9,
9950        };
9951        let mut sub_next_id = 1usize;
9952        let _ = collect_preview_rows(
9953            &sub_abs,
9954            &sub_abs,
9955            0,
9956            None,
9957            &mut sub_next_id,
9958            &mut sub_budget,
9959            &mut sub_stats,
9960            &mut sub_rows,
9961            &mut sub_langs,
9962            &[],
9963            &[],
9964        );
9965        let stats_json = format!(
9966            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
9967            sub_stats.directories,
9968            sub_stats.files,
9969            sub_stats.supported,
9970            sub_stats.skipped,
9971            sub_stats.unsupported
9972        );
9973        write!(
9974            out,
9975            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>"#,
9976            escape_html(sub_name),
9977            escape_html(&sub_rel_path.to_string_lossy()),
9978            escape_html(&sub_size),
9979            escape_html(&stats_json),
9980            escape_html(sub_name),
9981            escape_html(&sub_size),
9982        )
9983        .ok();
9984    }
9985    out.push_str(
9986        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
9987    );
9988    out.push_str(r"</div>");
9989}
9990
9991fn render_language_pills_row(languages: &[&str], out: &mut String) {
9992    use std::fmt::Write as _;
9993    if languages.is_empty() {
9994        out.push_str(
9995            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
9996        );
9997        return;
9998    }
9999    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
10000    for language in languages {
10001        if let Some(icon) = language_icon_file(language) {
10002            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();
10003        } else if let Some(svg) = language_inline_svg(language) {
10004            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();
10005        } else {
10006            write!(
10007                out,
10008                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
10009                escape_html(&language.to_ascii_lowercase()),
10010                escape_html(language)
10011            )
10012            .ok();
10013        }
10014    }
10015}
10016
10017#[allow(clippy::too_many_lines)]
10018fn build_preview_html(
10019    root: &Path,
10020    include_patterns: &[String],
10021    exclude_patterns: &[String],
10022) -> Result<String> {
10023    if !root.exists() {
10024        return Ok(format!(
10025            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
10026            escape_html(&display_path(root))
10027        ));
10028    }
10029
10030    let _selected = display_path(root);
10031    let mut stats = PreviewStats::default();
10032    let mut rows = Vec::new();
10033    let mut languages = Vec::new();
10034    let mut budget = PreviewBudget {
10035        shown: 0,
10036        max_entries: 600,
10037        max_depth: 9,
10038    };
10039    let mut next_row_id = 1usize;
10040
10041    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
10042        || root.to_string_lossy().into_owned(),
10043        std::string::ToString::to_string,
10044    );
10045    let root_modified = root
10046        .metadata()
10047        .ok()
10048        .and_then(|meta| meta.modified().ok())
10049        .map_or_else(|| "-".to_string(), format_system_time);
10050
10051    rows.push(PreviewRow {
10052        row_id: 0,
10053        parent_row_id: None,
10054        depth: 0,
10055        name: format!("{root_name}/"),
10056        kind: PreviewKind::Dir,
10057        is_dir: true,
10058        language: None,
10059        modified: root_modified,
10060        type_label: "Directory".to_string(),
10061    });
10062    collect_preview_rows(
10063        root,
10064        root,
10065        0,
10066        Some(0),
10067        &mut next_row_id,
10068        &mut budget,
10069        &mut stats,
10070        &mut rows,
10071        &mut languages,
10072        include_patterns,
10073        exclude_patterns,
10074    )?;
10075
10076    let root_size = format_dir_size(dir_size_bytes(root));
10077
10078    let mut out = String::new();
10079    write!(
10080        out,
10081        r#"<div class="explorer-wrap" data-project-size="{}">"#,
10082        escape_html(&root_size)
10083    )
10084    .ok();
10085    out.push_str(r#"<div class="explorer-toolbar compact">"#);
10086    out.push_str(r#"<div class="explorer-title-group">"#);
10087    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
10088    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
10089    out.push_str(r"</div></div>");
10090
10091    out.push_str(r#"<div class="scope-stats">"#);
10092    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();
10093    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();
10094    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();
10095    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();
10096    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();
10097    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>"#);
10098    out.push_str(r"</div>");
10099
10100    let submodules = sloc_core::detect_submodules(root);
10101    if !submodules.is_empty() {
10102        render_submodule_chips(root, &submodules, &mut out);
10103    }
10104
10105    out.push_str(r#"<div class="scope-info-row">"#);
10106    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
10107    render_language_pills_row(&languages, &mut out);
10108    out.push_str(r"</div></div>");
10109    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>"#);
10110    out.push_str(r"</div>");
10111
10112    out.push_str(r#"<div class="file-explorer-shell">"#);
10113    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>"#);
10114    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>"#);
10115    out.push_str(r#"<div class="file-explorer-tree">"#);
10116    for row in rows {
10117        let status_label = row.kind.label();
10118        let lang_attr = row.language.unwrap_or("");
10119        let toggle_html = if row.is_dir {
10120            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
10121                .to_string()
10122        } else {
10123            r#"<span class="tree-bullet">•</span>"#.to_string()
10124        };
10125        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();
10126    }
10127    if budget.shown >= budget.max_entries {
10128        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>"#);
10129    }
10130    out.push_str(r"</div></div></div>");
10131
10132    Ok(out)
10133}
10134
10135#[derive(Default)]
10136struct PreviewStats {
10137    directories: usize,
10138    files: usize,
10139    supported: usize,
10140    skipped: usize,
10141    unsupported: usize,
10142}
10143
10144struct PreviewRow {
10145    row_id: usize,
10146    parent_row_id: Option<usize>,
10147    depth: usize,
10148    name: String,
10149    kind: PreviewKind,
10150    is_dir: bool,
10151    language: Option<&'static str>,
10152    modified: String,
10153    type_label: String,
10154}
10155
10156#[derive(Copy, Clone)]
10157enum PreviewKind {
10158    Dir,
10159    Supported,
10160    Skipped,
10161    Unsupported,
10162}
10163
10164impl PreviewKind {
10165    const fn filter_key(self) -> &'static str {
10166        match self {
10167            Self::Dir => "dir",
10168            Self::Supported => "supported",
10169            Self::Skipped => "skipped",
10170            Self::Unsupported => "unsupported",
10171        }
10172    }
10173
10174    const fn label(self) -> &'static str {
10175        match self {
10176            Self::Dir => "dir",
10177            Self::Supported => "supported",
10178            Self::Skipped => "skipped by policy",
10179            Self::Unsupported => "unsupported",
10180        }
10181    }
10182
10183    const fn badge_class(self) -> &'static str {
10184        match self {
10185            Self::Dir => "badge badge-dir",
10186            Self::Supported => "badge badge-scan",
10187            Self::Skipped => "badge badge-skip",
10188            Self::Unsupported => "badge badge-unsupported",
10189        }
10190    }
10191
10192    const fn node_class(self) -> &'static str {
10193        match self {
10194            Self::Dir => "tree-node-dir",
10195            Self::Supported => "tree-node-supported",
10196            Self::Skipped => "tree-node-skipped",
10197            Self::Unsupported => "tree-node-unsupported",
10198        }
10199    }
10200}
10201
10202struct PreviewBudget {
10203    shown: usize,
10204    max_entries: usize,
10205    max_depth: usize,
10206}
10207
10208/// Handle a single directory entry inside `collect_preview_rows`.
10209/// Returns `true` when the entry was handled (caller should `continue`).
10210#[allow(clippy::too_many_arguments)]
10211fn handle_preview_dir_entry(
10212    root: &Path,
10213    path: &Path,
10214    name: &str,
10215    modified: String,
10216    depth: usize,
10217    parent_row_id: Option<usize>,
10218    row_id: usize,
10219    next_row_id: &mut usize,
10220    budget: &mut PreviewBudget,
10221    stats: &mut PreviewStats,
10222    rows: &mut Vec<PreviewRow>,
10223    languages: &mut Vec<&'static str>,
10224    include_patterns: &[String],
10225    exclude_patterns: &[String],
10226) -> Result<()> {
10227    let relative = preview_relative_path(root, path);
10228    if should_skip_preview_directory(&relative, exclude_patterns) {
10229        return Ok(());
10230    }
10231    stats.directories += 1;
10232    rows.push(PreviewRow {
10233        row_id,
10234        parent_row_id,
10235        depth: depth + 1,
10236        name: format!("{name}/"),
10237        kind: PreviewKind::Dir,
10238        is_dir: true,
10239        language: None,
10240        modified,
10241        type_label: "Directory".to_string(),
10242    });
10243    budget.shown += 1;
10244    if !matches!(name, ".git" | "node_modules" | "target") {
10245        collect_preview_rows(
10246            root,
10247            path,
10248            depth + 1,
10249            Some(row_id),
10250            next_row_id,
10251            budget,
10252            stats,
10253            rows,
10254            languages,
10255            include_patterns,
10256            exclude_patterns,
10257        )?;
10258    }
10259    Ok(())
10260}
10261
10262/// Handle a single file entry inside `collect_preview_rows`.
10263#[allow(clippy::too_many_arguments)]
10264fn handle_preview_file_entry(
10265    root: &Path,
10266    path: &Path,
10267    name: &str,
10268    modified: String,
10269    depth: usize,
10270    parent_row_id: Option<usize>,
10271    row_id: usize,
10272    budget: &mut PreviewBudget,
10273    stats: &mut PreviewStats,
10274    rows: &mut Vec<PreviewRow>,
10275    languages: &mut Vec<&'static str>,
10276    include_patterns: &[String],
10277    exclude_patterns: &[String],
10278) {
10279    let relative = preview_relative_path(root, path);
10280    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
10281        return;
10282    }
10283    stats.files += 1;
10284    let kind = classify_preview_file(name);
10285    match kind {
10286        PreviewKind::Supported => stats.supported += 1,
10287        PreviewKind::Skipped => stats.skipped += 1,
10288        PreviewKind::Unsupported => stats.unsupported += 1,
10289        PreviewKind::Dir => {}
10290    }
10291    let language = detect_language_name(name);
10292    if let Some(lang) = language {
10293        if !languages.contains(&lang) {
10294            languages.push(lang);
10295        }
10296    }
10297    rows.push(PreviewRow {
10298        row_id,
10299        parent_row_id,
10300        depth: depth + 1,
10301        name: name.to_owned(),
10302        kind,
10303        is_dir: false,
10304        language,
10305        modified,
10306        type_label: preview_type_label(name, language, kind),
10307    });
10308    budget.shown += 1;
10309}
10310
10311#[allow(clippy::too_many_arguments)]
10312#[allow(clippy::too_many_lines)]
10313fn collect_preview_rows(
10314    root: &Path,
10315    dir: &Path,
10316    depth: usize,
10317    parent_row_id: Option<usize>,
10318    next_row_id: &mut usize,
10319    budget: &mut PreviewBudget,
10320    stats: &mut PreviewStats,
10321    rows: &mut Vec<PreviewRow>,
10322    languages: &mut Vec<&'static str>,
10323    include_patterns: &[String],
10324    exclude_patterns: &[String],
10325) -> Result<()> {
10326    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
10327        return Ok(());
10328    }
10329
10330    let mut entries = fs::read_dir(dir)
10331        .with_context(|| format!("failed to read directory {}", dir.display()))?
10332        .filter_map(std::result::Result::ok)
10333        .collect::<Vec<_>>();
10334    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
10335
10336    for entry in entries {
10337        if budget.shown >= budget.max_entries {
10338            break;
10339        }
10340
10341        let path = entry.path();
10342        let name = entry.file_name().to_string_lossy().into_owned();
10343        let Ok(metadata) = entry.metadata() else {
10344            continue;
10345        };
10346        let row_id = *next_row_id;
10347        *next_row_id += 1;
10348        let modified = metadata
10349            .modified()
10350            .ok()
10351            .map_or_else(|| "-".to_string(), format_system_time);
10352
10353        if metadata.is_dir() {
10354            handle_preview_dir_entry(
10355                root,
10356                &path,
10357                &name,
10358                modified,
10359                depth,
10360                parent_row_id,
10361                row_id,
10362                next_row_id,
10363                budget,
10364                stats,
10365                rows,
10366                languages,
10367                include_patterns,
10368                exclude_patterns,
10369            )?;
10370            continue;
10371        }
10372
10373        if metadata.is_file() {
10374            handle_preview_file_entry(
10375                root,
10376                &path,
10377                &name,
10378                modified,
10379                depth,
10380                parent_row_id,
10381                row_id,
10382                budget,
10383                stats,
10384                rows,
10385                languages,
10386                include_patterns,
10387                exclude_patterns,
10388            );
10389        }
10390    }
10391
10392    Ok(())
10393}
10394
10395fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
10396    if let Some(language) = language {
10397        return format!("{language} source");
10398    }
10399    let lower = name.to_ascii_lowercase();
10400    let ext = Path::new(&lower)
10401        .extension()
10402        .and_then(|e| e.to_str())
10403        .unwrap_or("");
10404    match kind {
10405        PreviewKind::Skipped => {
10406            if lower.ends_with(".min.js") {
10407                "Minified asset".to_string()
10408            } else if [
10409                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
10410            ]
10411            .contains(&ext)
10412            {
10413                "Binary or archive".to_string()
10414            } else {
10415                "Skipped file".to_string()
10416            }
10417        }
10418        PreviewKind::Unsupported => {
10419            if ext.is_empty() {
10420                "Unsupported file".to_string()
10421            } else {
10422                format!("{} file", ext.to_ascii_uppercase())
10423            }
10424        }
10425        PreviewKind::Supported => "Supported source".to_string(),
10426        PreviewKind::Dir => "Directory".to_string(),
10427    }
10428}
10429
10430fn format_system_time(time: SystemTime) -> String {
10431    #[allow(clippy::cast_possible_wrap)]
10432    let secs = match time.duration_since(UNIX_EPOCH) {
10433        Ok(duration) => duration.as_secs() as i64,
10434        Err(_) => return "-".to_string(),
10435    };
10436    let days = secs.div_euclid(86_400);
10437    let secs_of_day = secs.rem_euclid(86_400);
10438    let (year, month, day) = civil_from_days(days);
10439    let hour = secs_of_day / 3_600;
10440    let minute = (secs_of_day % 3_600) / 60;
10441    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
10442}
10443
10444#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
10445fn civil_from_days(days: i64) -> (i32, u32, u32) {
10446    let z = days + 719_468;
10447    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
10448    let doe = z - era * 146_097;
10449    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
10450    let y = yoe + era * 400;
10451    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
10452    let mp = (5 * doy + 2) / 153;
10453    let d = doy - (153 * mp + 2) / 5 + 1;
10454    let m = mp + if mp < 10 { 3 } else { -9 };
10455    let year = y + i64::from(m <= 2);
10456    (year as i32, m as u32, d as u32)
10457}
10458
10459// The input is already lowercased via `to_ascii_lowercase()` before calling
10460// `ends_with`, so the comparisons are inherently case-insensitive.
10461#[allow(clippy::case_sensitive_file_extension_comparisons)]
10462fn detect_language_name(name: &str) -> Option<&'static str> {
10463    let lower = name.to_ascii_lowercase();
10464    if lower.ends_with(".c") || lower.ends_with(".h") {
10465        Some("C")
10466    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
10467        .iter()
10468        .any(|s| lower.ends_with(s))
10469    {
10470        Some("C++")
10471    } else if lower.ends_with(".cs") {
10472        Some("C#")
10473    } else if lower.ends_with(".py") {
10474        Some("Python")
10475    } else if lower.ends_with(".sh") {
10476        Some("Shell")
10477    } else if [".ps1", ".psm1", ".psd1"]
10478        .iter()
10479        .any(|s| lower.ends_with(s))
10480    {
10481        Some("PowerShell")
10482    } else {
10483        None
10484    }
10485}
10486
10487fn language_icon_file(language: &str) -> Option<&'static str> {
10488    match language {
10489        "C" => Some("c.png"),
10490        "C++" => Some("cpp.png"),
10491        "C#" => Some("c-sharp.png"),
10492        "Python" => Some("python.png"),
10493        "Shell" => Some("shell.png"),
10494        "PowerShell" => Some("powershell.png"),
10495        "JavaScript" => Some("java-script.png"),
10496        "HTML" => Some("html-5.png"),
10497        "Java" => Some("java.png"),
10498        "Visual Basic" => Some("visual-basic.png"),
10499        "Assembly" => Some("asm.png"),
10500        "Go" => Some("go.png"),
10501        "R" => Some("r.png"),
10502        "XML" => Some("xml.png"),
10503        "Groovy" => Some("groovy.png"),
10504        "Dockerfile" => Some("docker.png"),
10505        "Makefile" => Some("makefile.svg"),
10506        "Perl" => Some("perl.svg"),
10507        _ => None,
10508    }
10509}
10510
10511// Inline SVG badges for languages that have no PNG icon in images/icons/.
10512// Using inline SVG keeps the web UI fully self-contained — no extra files
10513// needed on disk, no 404s on air-gapped deployments.
10514// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
10515fn language_inline_svg(language: &str) -> Option<&'static str> {
10516    match language {
10517        "Rust" => Some(
10518            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>"##,
10519        ),
10520        "TypeScript" => Some(
10521            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>"##,
10522        ),
10523        _ => None,
10524    }
10525}
10526
10527// The input is already lowercased via `to_ascii_lowercase()` before the
10528// `ends_with` calls, so these comparisons are inherently case-insensitive.
10529#[allow(clippy::case_sensitive_file_extension_comparisons)]
10530fn classify_preview_file(name: &str) -> PreviewKind {
10531    let lower = name.to_ascii_lowercase();
10532
10533    let scannable = [
10534        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
10535        ".psm1", ".psd1",
10536    ]
10537    .iter()
10538    .any(|suffix| lower.ends_with(suffix));
10539
10540    if scannable {
10541        PreviewKind::Supported
10542    } else if lower.ends_with(".min.js")
10543        || lower.ends_with(".lock")
10544        || lower.ends_with(".png")
10545        || lower.ends_with(".jpg")
10546        || lower.ends_with(".jpeg")
10547        || lower.ends_with(".gif")
10548        || lower.ends_with(".zip")
10549        || lower.ends_with(".pdf")
10550        || lower.ends_with(".pyc")
10551        || lower.ends_with(".xz")
10552        || lower.ends_with(".tar")
10553        || lower.ends_with(".gz")
10554    {
10555        PreviewKind::Skipped
10556    } else {
10557        PreviewKind::Unsupported
10558    }
10559}
10560
10561fn preview_relative_path(root: &Path, path: &Path) -> String {
10562    path.strip_prefix(root)
10563        .ok()
10564        .unwrap_or(path)
10565        .to_string_lossy()
10566        .replace('\\', "/")
10567        .trim_matches('/')
10568        .to_string()
10569}
10570
10571fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
10572    if relative.is_empty() {
10573        return false;
10574    }
10575
10576    exclude_patterns.iter().any(|pattern| {
10577        wildcard_match(pattern, relative)
10578            || wildcard_match(pattern, &format!("{relative}/"))
10579            || wildcard_match(pattern, &format!("{relative}/placeholder"))
10580    })
10581}
10582
10583fn should_include_preview_file(
10584    relative: &str,
10585    include_patterns: &[String],
10586    exclude_patterns: &[String],
10587) -> bool {
10588    if relative.is_empty() {
10589        return true;
10590    }
10591
10592    let included = include_patterns.is_empty()
10593        || include_patterns
10594            .iter()
10595            .any(|pattern| wildcard_match(pattern, relative));
10596    let excluded = exclude_patterns
10597        .iter()
10598        .any(|pattern| wildcard_match(pattern, relative));
10599
10600    included && !excluded
10601}
10602
10603fn wildcard_match(pattern: &str, candidate: &str) -> bool {
10604    let pattern = pattern.trim().replace('\\', "/");
10605    let candidate = candidate.trim().replace('\\', "/");
10606    let p = pattern.as_bytes();
10607    let c = candidate.as_bytes();
10608    let mut pi = 0usize;
10609    let mut ci = 0usize;
10610    let mut star: Option<usize> = None;
10611    let mut star_match = 0usize;
10612
10613    while ci < c.len() {
10614        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
10615            pi += 1;
10616            ci += 1;
10617        } else if pi < p.len() && p[pi] == b'*' {
10618            while pi < p.len() && p[pi] == b'*' {
10619                pi += 1;
10620            }
10621            star = Some(pi);
10622            star_match = ci;
10623        } else if let Some(star_pi) = star {
10624            star_match += 1;
10625            ci = star_match;
10626            pi = star_pi;
10627        } else {
10628            return false;
10629        }
10630    }
10631
10632    while pi < p.len() && p[pi] == b'*' {
10633        pi += 1;
10634    }
10635
10636    pi == p.len()
10637}
10638
10639fn escape_html(value: &str) -> String {
10640    value
10641        .replace('&', "&amp;")
10642        .replace('<', "&lt;")
10643        .replace('>', "&gt;")
10644        .replace('"', "&quot;")
10645        .replace('\'', "&#39;")
10646}
10647
10648#[derive(Clone)]
10649struct SubmoduleRow {
10650    name: String,
10651    relative_path: String,
10652    files_analyzed: u64,
10653    code_lines: u64,
10654    comment_lines: u64,
10655    blank_lines: u64,
10656    total_physical_lines: u64,
10657    html_url: Option<String>,
10658}
10659
10660#[derive(Template)]
10661#[template(
10662    source = r##"
10663<!doctype html>
10664<html lang="en">
10665<head>
10666  <meta charset="utf-8">
10667  <title>OxideSLOC | tmp-sloc</title>
10668  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10669  <style nonce="{{ csp_nonce }}">
10670    :root {
10671      --bg: #efe9e2;
10672      --surface: #fcfaf7;
10673      --surface-2: #f7f0e8;
10674      --surface-3: #efe3d5;
10675      --line: #dfcfbf;
10676      --line-strong: #cfb29c;
10677      --text: #2f241c;
10678      --muted: #6f6257;
10679      --muted-2: #917f71;
10680      --nav: #b85d33;
10681      --nav-2: #7a371b;
10682      --accent: #2563eb;
10683      --accent-2: #1d4ed8;
10684      --oxide: #b85d33;
10685      --oxide-2: #8f4220;
10686      --success-bg: #eaf9ee;
10687      --success-text: #1c8746;
10688      --warn-bg: #fff2d8;
10689      --warn-text: #926000;
10690      --danger-bg: #fdeaea;
10691      --danger-text: #b33b3b;
10692      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
10693      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
10694      --radius: 14px;
10695    }
10696
10697    body.dark-theme {
10698      --bg: #1b1511;
10699      --surface: #261c17;
10700      --surface-2: #2d221d;
10701      --surface-3: #372922;
10702      --line: #524238;
10703      --line-strong: #6c5649;
10704      --text: #f5ece6;
10705      --muted: #c7b7aa;
10706      --muted-2: #aa9485;
10707      --nav: #b85d33;
10708      --nav-2: #7a371b;
10709      --accent: #6f9bff;
10710      --accent-2: #4a78ee;
10711      --oxide: #d37a4c;
10712      --oxide-2: #b35428;
10713      --success-bg: #163927;
10714      --success-text: #8fe2a8;
10715      --warn-bg: #3c2d11;
10716      --warn-text: #f3cb75;
10717      --danger-bg: #3d1f1f;
10718      --danger-text: #ff9f9f;
10719      --shadow: 0 14px 28px rgba(0,0,0,0.28);
10720      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
10721    }
10722
10723    * { box-sizing: border-box; }
10724    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); }
10725    html { overflow-y: scroll; }
10726    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
10727    .top-nav, .page, .loading { position: relative; z-index: 2; }
10728    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
10729    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
10730    .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); }
10731    .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; }
10732    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
10733    .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)); }
10734    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
10735    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
10736    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
10737    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
10738    .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; }
10739    .nav-project-pill.visible { display:inline-flex; }
10740    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
10741    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
10742    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
10743    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
10744    @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; } }
10745    .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; }
10746    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
10747    .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; }
10748    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
10749    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
10750    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
10751    .theme-toggle .icon-sun { display:none; }
10752    body.dark-theme .theme-toggle .icon-sun { display:block; }
10753    body.dark-theme .theme-toggle .icon-moon { display:none; }
10754    .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;}
10755    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
10756    .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);}
10757    .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;}
10758    .settings-close:hover{color:var(--text);background:var(--surface-2);}
10759    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
10760    .settings-modal-body{padding:14px 16px 16px;}
10761    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
10762    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
10763    .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;}
10764    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
10765    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
10766    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
10767    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
10768    .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;}
10769    .tz-select:focus{border-color:var(--oxide);}
10770    .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; }
10771    .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;}
10772    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
10773    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
10774    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
10775    .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; }
10776    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
10777    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
10778    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
10779    .wb-stats-header { padding: 10px 24px 0; }
10780    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
10781    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
10782    .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; }
10783    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
10784    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
10785    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
10786    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
10787    .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; }
10788    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
10789    .ws-stat-analyzers { position: relative; }
10790    .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; }
10791    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
10792    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
10793    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
10794    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
10795    .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; }
10796    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
10797    .ws-divider { display: none; }
10798    .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%; }
10799    .ws-path-link:hover { color:var(--oxide); }
10800    body.dark-theme .ws-path-link { color:var(--oxide); }
10801    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
10802    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
10803    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
10804    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
10805    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
10806    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
10807    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
10808    .ws-mini-box-lg { flex:2 1 0; }
10809    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
10810    .ws-mini-box-br { flex:1.5 1 0; }
10811    .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); }
10812    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
10813    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
10814    #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; }
10815    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
10816    .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; }
10817    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
10818    .git-source-banner strong { font-weight:800; color:var(--text); }
10819    .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; }
10820    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
10821    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
10822    .git-source-banner a:hover { text-decoration:underline; }
10823    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
10824    .path-scope-sep { background:var(--line); margin:4px 14px; }
10825    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
10826    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
10827    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
10828    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
10829    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
10830    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
10831    .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; }
10832    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
10833    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
10834    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
10835    .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; }
10836    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
10837    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
10838    [data-wb-tip] { cursor:help; }
10839    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
10840    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
10841    .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; }
10842    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
10843    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
10844    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
10845    .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; }
10846    .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); }
10847    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
10848    .side-info-card { padding: 18px; }
10849    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
10850    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
10851    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
10852    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
10853    .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); }
10854    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
10855    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
10856    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
10857    .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; }
10858    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
10859    .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; }
10860    .side-stack::-webkit-scrollbar { display: none; }
10861    .step-nav { padding: 20px 16px; }
10862    .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); }
10863    .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; }
10864    .step-button:hover { background: var(--surface-2); }
10865    .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); }
10866    .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; }
10867    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
10868    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
10869    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
10870    .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); }
10871    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
10872    .step-nav-sum-row:last-child { border-bottom:none; }
10873    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
10874    .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; }
10875    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
10876    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
10877    .quick-scan-section { padding: 10px 4px 14px; }
10878    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
10879    .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; }
10880    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
10881    .quick-scan-btn:active { transform:translateY(0); }
10882    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
10883    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
10884    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
10885    @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);} }
10886    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
10887    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
10888    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
10889    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
10890    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
10891    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
10892    .step-button.done .step-check { opacity:1; }
10893    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
10894    .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; }
10895    .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; }
10896    .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; }
10897    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
10898    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
10899    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
10900    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
10901    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
10902    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
10903    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
10904    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
10905    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
10906    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
10907    .card-body { padding: 22px; }
10908    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
10909    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
10910    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
10911    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
10912    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
10913    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
10914    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
10915    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
10916    .field { min-width:0; }
10917    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
10918    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; }
10919    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); }
10920    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
10921    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); }
10922    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
10923    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
10924    .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; }
10925    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
10926    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
10927    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
10928    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
10929    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
10930    .input-group.compact { grid-template-columns: 1fr auto auto; }
10931    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
10932    .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)); }
10933    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
10934    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
10935    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
10936    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
10937    .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; }
10938    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
10939    .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; }
10940    .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); }
10941    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
10942    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
10943    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
10944    button.secondary { background: var(--surface); }
10945    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
10946    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
10947    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
10948    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
10949    .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); }
10950    .section + .wizard-actions { border-top: none; padding-top: 0; }
10951    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
10952    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
10953    .field-help-grid.coupled-help { margin-top: 12px; }
10954    .field-help-grid.preset-grid { align-items: start; }
10955    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
10956    .preset-inline-row .field { margin: 0; }
10957    .preset-inline-row .explainer-card { margin: 0; }
10958    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
10959    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
10960    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
10961    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
10962    .preset-kv-row > :last-child { flex:1; min-width:0; }
10963    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
10964    .output-field-row .field { margin: 0; }
10965    .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; }
10966    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
10967    .step3-subtitle { margin-bottom: 10px; max-width: none; }
10968    .counting-intro { margin-bottom: 8px; max-width: none; }
10969    .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; }
10970    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
10971    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
10972    .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; }
10973    .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; }
10974    .section-spacer-top { margin-top: 28px; }
10975    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
10976    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
10977    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
10978    .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); }
10979    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
10980    .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; }
10981    .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; }
10982    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
10983    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
10984    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
10985    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
10986    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
10987    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
10988    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
10989    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
10990    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
10991    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
10992    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
10993    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
10994    .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); }
10995    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
10996    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
10997    .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; }
10998    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
10999    .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; }
11000    .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; }
11001    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
11002    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
11003    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
11004    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
11005    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
11006    .advanced-rule-description strong { color: var(--text); }
11007    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
11008    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
11009    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
11010    .review-link:hover { text-decoration: underline; }
11011    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
11012    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
11013    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
11014    .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; }
11015    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
11016    .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
11017    .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
11018    body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
11019    .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
11020    body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
11021    .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; }
11022    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
11023    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
11024    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
11025    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11026    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
11027    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
11028    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
11029    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
11030    .review-card ul { padding-left: 18px; margin: 0; }
11031    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
11032    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
11033    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
11034    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
11035    .review-card { min-height: 0; }
11036    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
11037    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
11038    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
11039    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
11040    .lang-overflow-chip { position:relative; cursor:default; }
11041    .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; }
11042    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
11043    .git-inline-row { align-items:start; }
11044    .mixed-line-card { display:flex; flex-direction:column; }
11045    .preset-inline-row .toggle-card { justify-content: center; }
11046        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
11047    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
11048    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
11049    .explorer-title { font-size: 18px; font-weight: 850; }
11050    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
11051    .explorer-subtitle.wide { max-width: none; }
11052    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
11053    .better-spacing { align-items:flex-start; justify-content:flex-end; }
11054    .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; }
11055    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
11056    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
11057    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
11058    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
11059    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
11060    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
11061    .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; }
11062    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
11063    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
11064    .scope-stat-button.supported { background: var(--success-bg); }
11065    .scope-stat-button.skipped { background: var(--warn-bg); }
11066    .scope-stat-button.unsupported { background: var(--danger-bg); }
11067    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
11068    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
11069    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
11070    [data-tooltip] { position: relative; }
11071    [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); }
11072    [data-tooltip]:hover::after { display: block; }
11073    .scope-stat-button[data-tooltip] { cursor: pointer; }
11074    .badge[data-tooltip] { cursor: help; }
11075    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
11076    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
11077    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
11078    .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; }
11079    .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; }
11080    code { display:inline-block; margin-top:0; padding:2px 7px; }
11081    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11082    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
11083    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
11084    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
11085    .language-pill.muted-pill { color: var(--muted); }
11086    button.language-pill { appearance:none; cursor:pointer; }
11087    .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); }
11088    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
11089    .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; }
11090    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
11091    .file-explorer-search-row { margin-left: auto; }
11092    .explorer-filter-select { min-width: 170px; width: 170px; }
11093    .explorer-search { min-width: 300px; width: 300px; }
11094    .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); }
11095    .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; }
11096    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
11097    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
11098    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
11099    .file-explorer-tree { max-height: 640px; overflow:auto; }
11100    .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); }
11101    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
11102    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
11103    .tree-row.hidden-by-filter { display:none !important; }
11104    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
11105    .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; }
11106    .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; }
11107    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
11108    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
11109    .tree-node { display:inline-flex; align-items:center; min-width:0; }
11110    .tree-node-dir { color: var(--text); font-weight: 800; }
11111    .tree-node-supported { color: var(--success-text); }
11112    .tree-node-skipped { color: var(--warn-text); }
11113    .tree-node-unsupported { color: var(--danger-text); }
11114    .tree-node-more { color: var(--muted-2); font-style: italic; }
11115    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
11116    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
11117    .tree-status-cell { display:flex; justify-content:flex-start; }
11118    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
11119    .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; }
11120    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
11121    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
11122    .cov-scan-idle { display:none; }
11123    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
11124    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
11125    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
11126    .cov-scan-title { font-weight:600; font-size:12.5px; }
11127    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
11128    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
11129    .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; }
11130    .cov-scan-use:hover { opacity:.75; }
11131    .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; }
11132    .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; }
11133    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
11134    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
11135    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
11136    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
11137    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
11138    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
11139    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
11140    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
11141    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
11142    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
11143    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
11144    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
11145    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
11146    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
11147    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
11148    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
11149    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
11150    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
11151    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
11152    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
11153    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
11154    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
11155    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
11156    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
11157    .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); }
11158    .loading.active { display:flex; }
11159    .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; }
11160    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
11161    .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; }
11162    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
11163    .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; }
11164    .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; }
11165    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
11166    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
11167    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
11168    .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; }
11169    .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
11170    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
11171    .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
11172    .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
11173    .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; }
11174    .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; }
11175    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
11176    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
11177    .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; }
11178    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
11179    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
11180    .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; }
11181    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
11182    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
11183    .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; }
11184    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
11185    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
11186    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
11187    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
11188    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
11189    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
11190    .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; }
11191    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
11192    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
11193    .hidden { display:none !important; }
11194    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11195    .site-footer a{color:var(--muted);}
11196    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
11197    @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; } }
11198    .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;}
11199    @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));}}
11200    .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;}
11201    .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; }
11202    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
11203    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
11204    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
11205    .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; }
11206    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
11207    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
11208    .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; }
11209    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
11210    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
11211    .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; }
11212    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
11213    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
11214    .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; }
11215    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
11216    .info-icon-btn:hover { color:var(--text); }
11217    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); }
11218    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
11219    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
11220    .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;}
11221    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
11222    .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;}
11223    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
11224  </style>
11225</head>
11226<body>
11227  <div class="background-watermarks" aria-hidden="true">
11228    <img src="/images/logo/logo-text.png" alt="" />
11229    <img src="/images/logo/logo-text.png" alt="" />
11230    <img src="/images/logo/logo-text.png" alt="" />
11231    <img src="/images/logo/logo-text.png" alt="" />
11232    <img src="/images/logo/logo-text.png" alt="" />
11233    <img src="/images/logo/logo-text.png" alt="" />
11234    <img src="/images/logo/logo-text.png" alt="" />
11235    <img src="/images/logo/logo-text.png" alt="" />
11236    <img src="/images/logo/logo-text.png" alt="" />
11237    <img src="/images/logo/logo-text.png" alt="" />
11238    <img src="/images/logo/logo-text.png" alt="" />
11239    <img src="/images/logo/logo-text.png" alt="" />
11240    <img src="/images/logo/logo-text.png" alt="" />
11241    <img src="/images/logo/logo-text.png" alt="" />
11242  </div>
11243  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11244  <div class="top-nav">
11245    <div class="top-nav-inner">
11246      <a class="brand" href="/">
11247        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
11248        <div class="brand-copy">
11249          <div class="brand-title">OxideSLOC</div>
11250          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
11251        </div>
11252      </a>
11253      <div class="nav-project-slot">
11254        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
11255          <span class="nav-project-label">Project</span>
11256          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
11257        </div>
11258      </div>
11259      <div class="nav-status">
11260        <a class="nav-pill" href="/">Home</a>
11261        <div class="nav-dropdown">
11262          <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>
11263          <div class="nav-dropdown-menu">
11264            <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>
11265          </div>
11266        </div>
11267        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11268        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11269        <div class="nav-dropdown">
11270          <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>
11271          <div class="nav-dropdown-menu">
11272            <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>
11273          </div>
11274        </div>
11275        <div class="server-status-wrap" id="server-status-wrap">
11276          <div class="nav-pill server-online-pill" id="server-status-pill">
11277            <span class="status-dot" id="status-dot"></span>
11278            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
11279            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11280          </div>
11281          <div class="server-status-tip">
11282            {% if server_mode %}
11283            OxideSLOC is running in server mode — accessible on your LAN.
11284            {% else %}
11285            OxideSLOC is running locally — only accessible from this machine.
11286            {% endif %}
11287            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11288          </div>
11289        </div>
11290        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11291          <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>
11292        </button>
11293        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
11294          <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>
11295          <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>
11296        </button>
11297      </div>
11298    </div>
11299  </div>
11300
11301  <div class="loading" id="loading">
11302    <div class="loading-card">
11303      <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
11304      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
11305      <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
11306      <div class="lc-path" id="lc-path"></div>
11307      <div class="lc-metrics" id="lc-metrics">
11308        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
11309        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
11310      </div>
11311      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
11312      <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>
11313      <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>
11314      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
11315      <div class="lc-actions hidden" id="lc-actions">
11316        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
11317        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
11318      </div>
11319      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
11320        <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>
11321        Cancel scan
11322      </button>
11323    </div>
11324  </div>
11325
11326  <div class="page">
11327    <div class="workbench-strip">
11328      <div class="workbench-box wb-stats">
11329        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
11330          <span class="wb-stats-title">Analysis session</span>
11331        </div>
11332        <div class="ws-left">
11333          <div class="ws-stat ws-stat-analyzers">
11334            <span class="ws-label">Analyzers</span>
11335            <span class="ws-value">
11336              <span class="ws-badge">41 languages</span>
11337            </span>
11338            <div class="ws-lang-tooltip">
11339              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
11340              <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>
11341              <div class="ws-lang-grid">
11342                <span class="ws-lang-item">Assembly</span>
11343                <span class="ws-lang-item">C</span>
11344                <span class="ws-lang-item">C++</span>
11345                <span class="ws-lang-item">C#</span>
11346                <span class="ws-lang-item">Clojure</span>
11347                <span class="ws-lang-item">CSS</span>
11348                <span class="ws-lang-item">Dart</span>
11349                <span class="ws-lang-item">Dockerfile</span>
11350                <span class="ws-lang-item">Elixir</span>
11351                <span class="ws-lang-item">Erlang</span>
11352                <span class="ws-lang-item">F#</span>
11353                <span class="ws-lang-item">Go</span>
11354                <span class="ws-lang-item">Groovy</span>
11355                <span class="ws-lang-item">Haskell</span>
11356                <span class="ws-lang-item">HTML</span>
11357                <span class="ws-lang-item">Java</span>
11358                <span class="ws-lang-item">JavaScript</span>
11359                <span class="ws-lang-item">Julia</span>
11360                <span class="ws-lang-item">Kotlin</span>
11361                <span class="ws-lang-item">Lua</span>
11362                <span class="ws-lang-item">Makefile</span>
11363                <span class="ws-lang-item">Nim</span>
11364                <span class="ws-lang-item">Obj-C</span>
11365                <span class="ws-lang-item">OCaml</span>
11366                <span class="ws-lang-item">Perl</span>
11367                <span class="ws-lang-item">PHP</span>
11368                <span class="ws-lang-item">PowerShell</span>
11369                <span class="ws-lang-item">Python</span>
11370                <span class="ws-lang-item">R</span>
11371                <span class="ws-lang-item">Ruby</span>
11372                <span class="ws-lang-item">Rust</span>
11373                <span class="ws-lang-item">Scala</span>
11374                <span class="ws-lang-item">SCSS</span>
11375                <span class="ws-lang-item">Shell</span>
11376                <span class="ws-lang-item">SQL</span>
11377                <span class="ws-lang-item">Svelte</span>
11378                <span class="ws-lang-item">Swift</span>
11379                <span class="ws-lang-item">TypeScript</span>
11380                <span class="ws-lang-item">Vue</span>
11381                <span class="ws-lang-item">XML</span>
11382                <span class="ws-lang-item">Zig</span>
11383              </div>
11384            </div>
11385          </div>
11386          <div class="ws-divider"></div>
11387          <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>
11388          <div class="ws-divider"></div>
11389          <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.">
11390            <span class="ws-label">Output</span>
11391            <span class="ws-value">
11392              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
11393                <span id="ws-output-root">project/sloc</span>
11394              </button>
11395            </span>
11396          </div>
11397        </div>
11398      </div>
11399      <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.">
11400        <div class="ws-history-label">Scan history</div>
11401        <div class="ws-history-inner">
11402          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
11403            <div class="ws-mini-label">Scans</div>
11404            <div class="ws-mini-value" id="ws-scan-count">—</div>
11405          </div>
11406          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
11407            <div class="ws-mini-label">Last Scan</div>
11408            <div class="ws-mini-value" id="ws-last-scan">—</div>
11409          </div>
11410          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
11411            <div class="ws-mini-label">Branch</div>
11412            <div class="ws-mini-value" id="ws-branch">—</div>
11413          </div>
11414        </div>
11415      </div>
11416    </div>
11417
11418    <div class="layout">
11419      <aside class="side-stack">
11420        <section class="step-nav">
11421        <h3>Guided scan setup</h3>
11422        <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>
11423        <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>
11424        <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>
11425        <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>
11426
11427        <div class="step-steps-divider"></div>
11428
11429        <div class="step-nav-info" id="step-nav-info">
11430          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
11431          <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>
11432        </div>
11433
11434        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
11435          <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>
11436          <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>
11437          <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>
11438        </div>
11439
11440        <div class="quick-scan-divider"></div>
11441        <div class="quick-scan-section">
11442          <div class="quick-scan-label">No customization needed?</div>
11443          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
11444            <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>
11445            Quick Scan
11446          </button>
11447          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
11448        </div>
11449
11450        <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>
11451        </section>
11452
11453      </aside>
11454
11455      <section class="card">
11456        <div class="card-header">
11457          <div class="card-title-row">
11458            <div>
11459              <h1 class="card-title">Guided scan configuration</h1>
11460              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
11461            </div>
11462            <div class="wizard-progress" aria-label="Scan setup progress">
11463              <div class="wizard-progress-top">
11464                <span class="wizard-progress-label">Setup progress</span>
11465                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
11466              </div>
11467              <div class="wizard-progress-track">
11468                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
11469              </div>
11470            </div>
11471          </div>
11472        </div>
11473        <div class="card-body">
11474          <form method="post" action="/analyze" id="analyze-form">
11475            <div class="wizard-step active" data-step="1">
11476              <div class="section">
11477                <div class="section-kicker">Step 1</div>
11478                <h2>Select project and preview scope</h2>
11479                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
11480                <div class="field">
11481                  <label for="path">Project path</label>
11482                  {% if !git_repo.is_empty() %}
11483                  <div class="git-source-banner">
11484                    <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>
11485                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
11486                    <a href="/git-browser">← Back to Git Browser</a>
11487                  </div>
11488                  {% endif %}
11489                  <div class="path-scope-grid">
11490                      {% if !git_repo.is_empty() %}
11491                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
11492                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
11493                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
11494                      {% else %}
11495                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
11496                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
11497                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
11498                      {% endif %}
11499                    <div class="path-scope-sep"></div>
11500                    <div class="scope-legend-row">
11501                      <span class="scope-legend-label">Scope legend:</span>
11502                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
11503                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
11504                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
11505                    </div>
11506                  </div>
11507                  {% if git_repo.is_empty() %}
11508                  {% if server_mode %}
11509                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
11510                    ℹ️ Files are compressed and streamed — no fixed size limit.
11511                  </div>
11512                  {% endif %}
11513                  <div class="path-info-row">
11514                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
11515                      <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>
11516                      <span id="project-size-text">Project size: —</span>
11517                    </button>
11518                  </div>
11519                  {% else %}
11520                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
11521                  {% endif %}
11522                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
11523                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
11524                </div>
11525
11526                <div class="scope-preview-divider" aria-hidden="true"></div>
11527
11528                <div id="preview-panel">
11529                  <div class="preview-error">Loading preview...</div>
11530                </div>
11531              </div>
11532
11533              <div class="section" style="margin-top:14px;">
11534                <div class="preset-inline-row git-inline-row">
11535                  <div class="toggle-card" style="margin:0;">
11536                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
11537                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
11538                    <label class="checkbox">
11539                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
11540                      <div>
11541                        <span>Detect and separate git submodules</span>
11542                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
11543                      </div>
11544                    </label>
11545                  </div>
11546                  <div class="explainer-card prominent" style="margin:0;">
11547                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11548                    <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>
11549                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
11550    path = libs/core
11551    url  = https://github.com/org/core.git
11552
11553[submodule "libs/ui"]
11554    path = libs/ui
11555    url  = https://github.com/org/ui.git</div>
11556                  </div>
11557                </div>
11558              </div>
11559
11560              <div class="section">
11561                <div class="field-grid">
11562                  <div class="field">
11563                    <label for="include_globs">Include globs</label>
11564                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
11565                    <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>
11566                  </div>
11567                  <div class="field">
11568                    <label for="exclude_globs">Exclude globs</label>
11569                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
11570                    <div id="quick-exclude-chips" class="quick-excl-row">
11571                      <span class="quick-excl-label">Quick add:</span>
11572                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
11573                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
11574                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
11575                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
11576                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
11577                      <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>
11578                    </div>
11579                    <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>
11580                  </div>
11581                </div>
11582                <div class="glob-guidance-grid">
11583                  <div class="glob-guidance-card">
11584                    <strong>How to read them</strong>
11585                    <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>
11586                  </div>
11587                  <div class="glob-guidance-card">
11588                    <strong>Common include examples</strong>
11589                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
11590                  </div>
11591                  <div class="glob-guidance-card">
11592                    <strong>Common exclude examples</strong>
11593                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
11594                  </div>
11595                </div>
11596              </div>
11597
11598              <div class="section" style="margin-top:14px;">
11599                <div class="preset-inline-row git-inline-row">
11600                  <div class="toggle-card" style="margin:0;">
11601                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
11602                    <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>
11603                    <div class="field" style="margin:0;">
11604                      <div class="input-group compact">
11605                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
11606                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
11607                      </div>
11608                      <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>
11609                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
11610                    </div>
11611                  </div>
11612                  <div class="explainer-card prominent" style="margin:0;">
11613                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11614                    <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>
11615                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
11616lcov --capture --directory . --output-file coverage/lcov.info
11617
11618# C / C++ — llvm-cov (LCOV)
11619llvm-profdata merge -sparse default.profraw -o default.profdata
11620llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
11621
11622# C# — coverlet (Cobertura XML)
11623dotnet test --collect:"XPlat Code Coverage"
11624
11625# Python — pytest-cov (Cobertura XML)
11626pytest --cov --cov-report=xml
11627
11628# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
11629./gradlew jacocoTestReport</div>
11630                  </div>
11631                </div>
11632              </div>
11633
11634              <div class="wizard-actions">
11635                <div class="left"></div>
11636                <div class="right">
11637                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
11638                </div>
11639              </div>
11640            </div>
11641
11642            <div class="wizard-step" data-step="2">
11643              <div class="section">
11644                <div class="section-kicker">Step 2</div>
11645                <h2>Choose counting behavior</h2>
11646                <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>
11647                <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
11648                <div class="subsection-bar">Primary line classification</div>
11649                <div class="preset-kv-row">
11650                  <div class="toggle-card mixed-line-card" style="margin:0;">
11651                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
11652                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
11653                    <select id="mixed_line_policy" name="mixed_line_policy">
11654                      <option value="code_only">Code only</option>
11655                      <option value="code_and_comment">Code and comment</option>
11656                      <option value="comment_only">Comment only</option>
11657                      <option value="separate_mixed_category">Separate mixed category</option>
11658                    </select>
11659                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
11660                  </div>
11661                  <div class="explainer-card prominent" style="margin:0;">
11662                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
11663                    <div class="explainer-body" id="mixed-policy-description"></div>
11664                    <div class="code-sample" id="mixed-policy-example"></div>
11665                  </div>
11666                </div>
11667              </div>
11668
11669              <div class="subsection-bar">Additional scan rules</div>
11670              <div class="scan-rules-grid">
11671                <div class="preset-inline-row">
11672                  <div class="toggle-card" style="margin:0;">
11673                    <div class="field-help-title">Generated files</div>
11674                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
11675                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11676                  </div>
11677                  <div class="explainer-card prominent" style="margin:0;">
11678                    <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>
11679                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
11680# Files matching codegen patterns are excluded:
11681#   *.generated.cs  *.pb.go  *.g.dart</div>
11682                  </div>
11683                </div>
11684                <div class="preset-inline-row">
11685                  <div class="toggle-card" style="margin:0;">
11686                    <div class="field-help-title">Minified files</div>
11687                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
11688                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11689                  </div>
11690                  <div class="explainer-card prominent" style="margin:0;">
11691                    <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>
11692                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
11693# Heuristic: very long lines + low whitespace ratio
11694#   jquery.min.js  bundle.min.css  → skipped</div>
11695                  </div>
11696                </div>
11697                <div class="preset-inline-row">
11698                  <div class="toggle-card" style="margin:0;">
11699                    <div class="field-help-title">Vendor directories</div>
11700                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
11701                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11702                  </div>
11703                  <div class="explainer-card prominent" style="margin:0;">
11704                    <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>
11705                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
11706# Directories named vendor/ node_modules/ third_party/
11707#   → entire subtree is excluded from totals</div>
11708                  </div>
11709                </div>
11710                <div class="preset-inline-row">
11711                  <div class="toggle-card" style="margin:0;">
11712                    <div class="field-help-title">Lockfiles and manifests</div>
11713                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
11714                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
11715                  </div>
11716                  <div class="explainer-card prominent" style="margin:0;">
11717                    <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>
11718                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
11719# Files like package-lock.json  Cargo.lock  yarn.lock
11720#   → skipped unless this is enabled</div>
11721                  </div>
11722                </div>
11723                <div class="preset-inline-row">
11724                  <div class="toggle-card" style="margin:0;">
11725                    <div class="field-help-title">Binary handling</div>
11726                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
11727                    <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>
11728                  </div>
11729                  <div class="explainer-card prominent" style="margin:0;">
11730                    <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>
11731                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
11732# Detected via long lines + low whitespace heuristic
11733#   .png  .exe  .so  → skipped silently</div>
11734                  </div>
11735                </div>
11736                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
11737                  <div class="toggle-card" style="margin:0;">
11738                    <div class="field-help-title">Python docstrings</div>
11739                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
11740                    <label class="checkbox">
11741                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
11742                      <span>Count as comment-style lines</span>
11743                    </label>
11744                  </div>
11745                  <div class="explainer-card prominent" style="margin:0;">
11746                    <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>
11747                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
11748                  </div>
11749                </div>
11750              </div>
11751              <div class="subsection-bar">IEEE 1045-1992 counting</div>
11752              <div class="scan-rules-grid">
11753                <div class="preset-inline-row">
11754                  <div class="toggle-card" style="margin:0;">
11755                    <div class="field-help-title">Continuation lines</div>
11756                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
11757                    <select name="continuation_line_policy" id="continuation_line_policy">
11758                      <option value="each_physical_line" selected>Each physical line (default)</option>
11759                      <option value="collapse_to_logical">Collapse to logical line</option>
11760                    </select>
11761                  </div>
11762                  <div class="explainer-card prominent" style="margin:0;">
11763                    <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>
11764                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
11765    ((a) &gt; (b) ? (a) : (b))
11766# each_physical_line → 2 SLOC
11767# collapse_to_logical → 1 SLOC</div>
11768                  </div>
11769                </div>
11770                <div class="preset-inline-row">
11771                  <div class="toggle-card" style="margin:0;">
11772                    <div class="field-help-title">Block-comment blanks</div>
11773                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
11774                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
11775                      <option value="count_as_comment" selected>Count as comment (default)</option>
11776                      <option value="count_as_blank">Count as blank</option>
11777                    </select>
11778                  </div>
11779                  <div class="explainer-card prominent" style="margin:0;">
11780                    <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>
11781                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
11782 * Summary line
11783 *              ← blank inside block comment
11784 * Detail line
11785 */
11786# count_as_comment → blank counts toward comments
11787# count_as_blank   → blank counts toward blanks</div>
11788                  </div>
11789                </div>
11790                <div class="preset-inline-row">
11791                  <div class="toggle-card" style="margin:0;">
11792                    <div class="field-help-title">Compiler directives</div>
11793                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
11794                    <select name="count_compiler_directives" id="count_compiler_directives">
11795                      <option value="enabled" selected>Include in code SLOC (default)</option>
11796                      <option value="disabled">Exclude from code SLOC</option>
11797                    </select>
11798                  </div>
11799                  <div class="explainer-card prominent" style="margin:0;">
11800                    <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>
11801                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
11802#define BUF 256     ← compiler directive
11803int main() { … }   ← code
11804# enabled  → 3 code SLOC
11805# disabled → 1 code SLOC + 2 directive lines</div>
11806                  </div>
11807                </div>
11808              </div>
11809
11810              <div class="always-tracked-tip">
11811                <div class="always-tracked-tip-icon">ℹ</div>
11812                <div class="always-tracked-tip-body">
11813                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
11814                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
11815                  <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>
11816                </div>
11817              </div>
11818
11819              <div class="wizard-actions">
11820                <div class="left">
11821                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
11822                </div>
11823                <div class="right">
11824                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
11825                </div>
11826              </div>
11827            </div>
11828
11829            <div class="wizard-step" data-step="3">
11830              <div class="section">
11831                <div class="section-kicker">Step 3</div>
11832                <h2>Output and report identity</h2>
11833                <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>
11834                <div class="preset-kv-row">
11835                  <div class="toggle-card" style="margin:0;">
11836                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
11837                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
11838                    <select id="scan_preset">
11839                      <option value="balanced">Balanced local scan</option>
11840                      <option value="code_focused">Code focused</option>
11841                      <option value="comment_audit">Comment audit</option>
11842                      <option value="deep_review">Deep review</option>
11843                    </select>
11844                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
11845                  </div>
11846                  <div class="explainer-card">
11847                    <div class="field-help-title">Selected scan preset</div>
11848                    <div class="explainer-body" id="scan-preset-description"></div>
11849                    <div class="preset-summary-row" id="scan-preset-summary"></div>
11850                    <div class="code-sample" id="scan-preset-example"></div>
11851                    <div class="preset-note" id="scan-preset-note"></div>
11852                  </div>
11853                </div>
11854                <hr class="step3-separator" />
11855                <div class="preset-kv-row">
11856                  <div class="toggle-card" style="margin:0;">
11857                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
11858                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
11859                    <select id="artifact_preset">
11860                      <option value="review">Review bundle</option>
11861                      <option value="full">Full bundle</option>
11862                      <option value="html_only">HTML only</option>
11863                      <option value="machine">Machine bundle</option>
11864                    </select>
11865                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
11866                  </div>
11867                  <div class="explainer-card">
11868                    <div class="field-help-title">Selected artifact preset</div>
11869                    <div class="explainer-body" id="artifact-preset-description"></div>
11870                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
11871                    <div class="code-sample" id="artifact-preset-example"></div>
11872                  </div>
11873                </div>
11874              </div>
11875
11876              <div class="section section-spacer-top">
11877                <div class="output-field-row">
11878                  <div class="field">
11879                    <label for="output_dir">Output directory</label>
11880                    {% if server_mode %}
11881                    <div class="input-group compact">
11882                      <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);" />
11883                    </div>
11884                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
11885                    {% else %}
11886                    <div class="input-group compact">
11887                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
11888                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
11889                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
11890                    </div>
11891                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
11892                    {% endif %}
11893                  </div>
11894                  <div class="output-field-aside">
11895                    <strong>Where reports land</strong>
11896                    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.
11897                  </div>
11898                </div>
11899              </div>
11900
11901              <div class="section section-spacer-top">
11902                <div class="output-field-row">
11903                  <div class="field">
11904                    <label for="report_title">Report title</label>
11905                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
11906                    <div class="hint">Appears in HTML and PDF output headers.</div>
11907                  </div>
11908                  <div class="output-field-aside">
11909                    <strong>Shown in exported artifacts</strong>
11910                    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.
11911                  </div>
11912                </div>
11913              </div>
11914
11915              <div class="section section-spacer-top">
11916                <div class="output-field-row">
11917                  <div class="field">
11918                    <label for="report_header_footer">Report header / footer</label>
11919                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
11920                    <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>
11921                  </div>
11922                  <div class="output-field-aside">
11923                    <strong>Page-level identification</strong>
11924                    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.
11925                  </div>
11926                </div>
11927              </div>
11928
11929              <div class="section">
11930                <div class="section-kicker">Artifacts</div>
11931                <div class="artifact-grid" style="margin-bottom:24px;">
11932                  <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
11933                    <div class="marker">✓</div>
11934                    <div class="artifact-icon">H</div>
11935                    <h4>HTML report</h4>
11936                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
11937                    <div class="artifact-tags">
11938                      <span class="soft-chip">Best for visual review</span>
11939                      <span class="soft-chip">Embeddable preview</span>
11940                    </div>
11941                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
11942                  </div>
11943                  <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
11944                    <div class="marker">✓</div>
11945                    <div class="artifact-icon">P</div>
11946                    <h4>PDF export</h4>
11947                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
11948                    <div class="artifact-tags">
11949                      <span class="soft-chip">Portable snapshot</span>
11950                      <span class="soft-chip">Good for handoff</span>
11951                    </div>
11952                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
11953                  </div>
11954                  <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
11955                    <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>
11956                    <div class="marker">✓</div>
11957                    <div class="artifact-icon" style="color:var(--muted);">J</div>
11958                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
11959                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
11960                    <div class="artifact-tags">
11961                      <span class="soft-chip">Required for compare</span>
11962                      <span class="soft-chip">Auto-enabled</span>
11963                    </div>
11964                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
11965                  </div>
11966                </div>
11967                <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>
11968              </div>
11969
11970              <div class="wizard-actions">
11971                <div class="left">
11972                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
11973                </div>
11974                <div class="right">
11975                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
11976                </div>
11977              </div>
11978            </div>
11979
11980            <div class="wizard-step" data-step="4">
11981              <div class="section">
11982                <div class="section-kicker">Step 4</div>
11983                <h2>Review selections and run</h2>
11984                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
11985                <div class="review-grid">
11986                  <div class="review-card highlight">
11987                    <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>
11988                    <ul id="review-scan-summary"></ul>
11989                  </div>
11990                  <div class="review-card highlight">
11991                    <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>
11992                    <ul id="review-count-summary"></ul>
11993                  </div>
11994                  <div class="review-card">
11995                    <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>
11996                    <ul id="review-artifact-summary"></ul>
11997                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
11998                  </div>
11999                  <div class="review-card">
12000                    <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>
12001                    <ul id="review-preview-summary"></ul>
12002                  </div>
12003                </div>
12004              </div>
12005
12006              <div class="wizard-actions">
12007                <div class="left">
12008                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
12009                </div>
12010                <div class="right">
12011                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
12012                </div>
12013              </div>
12014            </div>
12015            {% if server_mode %}
12016            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
12017            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
12018            {% endif %}
12019          </form>
12020        </div>
12021      </section>
12022    </div>
12023  </div>
12024
12025  <script nonce="{{ csp_nonce }}">
12026    (function () {
12027      function startScanPhase() {
12028        var phaseEl = document.getElementById("scan-phase");
12029        if (!phaseEl) return;
12030        var phases = [
12031          "Discovering files...",
12032          "Decoding file encodings...",
12033          "Detecting languages...",
12034          "Analyzing source lines...",
12035          "Applying counting policies...",
12036          "Aggregating results...",
12037          "Rendering report..."
12038        ];
12039        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
12040        var i = 0;
12041        function next() {
12042          phaseEl.style.opacity = "0";
12043          setTimeout(function () {
12044            phaseEl.textContent = phases[i];
12045            phaseEl.style.opacity = "0.85";
12046            var delay = durations[i] || 1800;
12047            i++;
12048            if (i < phases.length) { setTimeout(next, delay); }
12049          }, 200);
12050        }
12051        next();
12052      }
12053
12054      var form = document.getElementById("analyze-form");
12055      var loading = document.getElementById("loading");
12056      var submitButton = document.getElementById("submit-button");
12057      var pathInput = document.getElementById("path");
12058      var GIT_MODE = !!(pathInput && pathInput.readOnly);
12059      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
12060      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
12061      var outputDirInput = document.getElementById("output_dir");
12062      var reportTitleInput = document.getElementById("report_title");
12063      var previewPanel = document.getElementById("preview-panel");
12064      var refreshButton = document.getElementById("refresh-preview");
12065      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
12066      var useSamplePath = document.getElementById("use-sample-path");
12067      var useDefaultOutput = document.getElementById("use-default-output");
12068      var browsePath = document.getElementById("browse-path");
12069      var browseOutputDir = document.getElementById("browse-output-dir");
12070      var browseCoverage = document.getElementById("browse-coverage");
12071      var coverageInput = document.getElementById("coverage_file");
12072      var covScanStatus = document.getElementById("cov-scan-status");
12073      var coverageSuggestTimer = null;
12074      var covAutoFilled = false;
12075      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
12076      function fmtBytes(b) {
12077        b = Number(b) || 0;
12078        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
12079        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
12080        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
12081        return b + ' B';
12082      }
12083      var themeToggle = document.getElementById("theme-toggle");
12084
12085      function showBannerToast(msg, isError, opts) {
12086        opts = opts || {};
12087        var t = document.createElement('div');
12088        t.className = isError ? 'toast-error' : 'toast-success';
12089        var topPos = opts.top ? '80px' : null;
12090        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
12091          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
12092          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
12093          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
12094        if (opts.icon) {
12095          var inner = document.createElement('span');
12096          inner.innerHTML = opts.icon + ' ';
12097          t.appendChild(inner);
12098        }
12099        t.appendChild(document.createTextNode(msg));
12100        document.body.appendChild(t);
12101        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
12102      }
12103      var mixedLinePolicy = document.getElementById("mixed_line_policy");
12104      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
12105      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
12106      var scanPreset = document.getElementById("scan_preset");
12107      var artifactPreset = document.getElementById("artifact_preset");
12108      var includeGlobsInput = document.getElementById("include_globs");
12109      var excludeGlobsInput = document.getElementById("exclude_globs");
12110
12111      // Quick-exclude chips — append pattern to exclude_globs textarea.
12112      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
12113        chip.addEventListener("click", function() {
12114          var pattern = chip.getAttribute("data-pattern") || "";
12115          if (!pattern || !excludeGlobsInput) return;
12116          var current = excludeGlobsInput.value.trim();
12117          // For the "skip all" chip, replace any existing dep patterns cleanly.
12118          var patterns = pattern.split("\n");
12119          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
12120          var added = false;
12121          patterns.forEach(function(p) {
12122            p = p.trim();
12123            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
12124          });
12125          if (added) {
12126            excludeGlobsInput.value = lines.join("\n");
12127            excludeGlobsInput.dispatchEvent(new Event("input"));
12128          }
12129          chip.classList.add("active");
12130        });
12131      });
12132
12133      var liveReportTitle = document.getElementById("live-report-title");
12134      var navProjectPill = document.getElementById("nav-project-pill");
12135      var navProjectTitle = document.getElementById("nav-project-title");
12136      var reportTitlePreview = null;
12137      var wizardProgressFill = document.getElementById("wizard-progress-fill");
12138      var wizardProgressValue = document.getElementById("wizard-progress-value");
12139      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
12140      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
12141      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
12142      var reportTitleTouched = false;
12143      var currentStep = 1;
12144      var previewTimer = null;
12145      var quickScanBtn = document.getElementById("quick-scan-btn");
12146
12147      function dismissAnalysisModal() {
12148        if (loading) loading.classList.remove("active");
12149        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12150          var el = document.getElementById(id);
12151          if (el) el.classList.add("hidden");
12152        });
12153        var cancelBtn = document.getElementById("lc-cancel-btn");
12154        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
12155        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
12156        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
12157        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12158        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12159        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12160        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12161        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12162      }
12163
12164      var lcDismissBtn = document.getElementById("lc-dismiss");
12165      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
12166
12167      function startAsyncAnalysis(formData) {
12168        var gitRepo = (formData.get("git_repo") || "").toString();
12169        var gitRef  = (formData.get("git_ref")  || "").toString();
12170        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
12171        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
12172
12173        var pathEl = document.getElementById("lc-path");
12174        if (pathEl) pathEl.textContent = displayPath;
12175
12176        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12177          var el = document.getElementById(id);
12178          if (el) el.classList.add("hidden");
12179        });
12180        var cancelBtn = document.getElementById("lc-cancel-btn");
12181        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
12182        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12183        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12184        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12185        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
12186        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
12187
12188        if (loading) loading.classList.add("active");
12189
12190        var startTime = Date.now();
12191        var elapsedTimer = setInterval(function() {
12192          var s = Math.floor((Date.now() - startTime) / 1000);
12193          var el = document.getElementById("lc-elapsed");
12194          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
12195        }, 1000);
12196
12197        var warnShown = false, pollRetries = 0, activeWaitId = null;
12198
12199        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
12200
12201        function lcShowCancelled() {
12202          clearInterval(elapsedTimer);
12203          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
12204          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
12205          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
12206          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
12207          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
12208          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
12209          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
12210          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
12211          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12212          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12213        }
12214
12215        var lcCancelBtn = document.getElementById("lc-cancel-btn");
12216        if (lcCancelBtn) {
12217          lcCancelBtn.onclick = function() {
12218            if (!activeWaitId) { dismissAnalysisModal(); return; }
12219            lcCancelBtn.disabled = true;
12220            lcCancelBtn.textContent = "Cancelling…";
12221            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
12222              .then(function() { lcShowCancelled(); })
12223              .catch(function() { lcShowCancelled(); });
12224          };
12225        }
12226
12227        function lcShowError(msg) {
12228          clearInterval(elapsedTimer);
12229          lcSetPhase("Failed");
12230          var msgEl = document.getElementById("lc-err-msg");
12231          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
12232          var errEl = document.getElementById("lc-err");
12233          var actEl = document.getElementById("lc-actions");
12234          if (errEl) errEl.classList.remove("hidden");
12235          if (actEl) actEl.classList.remove("hidden");
12236          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12237          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12238        }
12239
12240        function lcPoll(waitId) {
12241          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
12242            .then(function(r) {
12243              if (!r.ok) throw new Error("HTTP " + r.status);
12244              return r.json();
12245            })
12246            .then(function(data) {
12247              pollRetries = 0;
12248              if (data.state === "complete") {
12249                clearInterval(elapsedTimer);
12250                lcSetPhase("Done");
12251                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
12252              } else if (data.state === "failed") {
12253                lcShowError(data.message);
12254              } else if (data.state === "cancelled") {
12255                lcShowCancelled();
12256              } else {
12257                var s = Math.floor((Date.now() - startTime) / 1000);
12258                if (s > 90 && !warnShown) {
12259                  warnShown = true;
12260                  var w = document.getElementById("lc-warn");
12261                  if (w) w.classList.remove("hidden");
12262                }
12263                lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
12264                setTimeout(function() { lcPoll(waitId); }, 1500);
12265              }
12266            })
12267            .catch(function() {
12268              pollRetries++;
12269              if (pollRetries >= 5) {
12270                lcShowError("Lost connection to server. Reload to check status.");
12271              } else {
12272                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
12273              }
12274            });
12275        }
12276
12277        var params = new URLSearchParams(formData);
12278        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
12279          .then(function(r) {
12280            var waitId = r.headers.get("x-wait-id");
12281            if (!waitId) { window.location.href = "/scan"; return; }
12282            activeWaitId = waitId;
12283            setTimeout(function() { lcPoll(waitId); }, 1500);
12284          })
12285          .catch(function(err) {
12286            lcShowError("Could not reach server: " + (err.message || err));
12287          });
12288      }
12289
12290      if (quickScanBtn) {
12291        quickScanBtn.addEventListener("click", function () {
12292          var pathVal = pathInput ? pathInput.value.trim() : "";
12293          if (!pathVal) {
12294            alert("Please enter or browse to a project path first.");
12295            return;
12296          }
12297          quickScanBtn.disabled = true;
12298          quickScanBtn.textContent = "Scanning...";
12299          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
12300          startAsyncAnalysis(new FormData(form));
12301        });
12302      }
12303
12304      var mixedPolicyInfo = {
12305        code_only: {
12306          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.",
12307          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'
12308        },
12309        code_and_comment: {
12310          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.",
12311          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'
12312        },
12313        comment_only: {
12314          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.",
12315          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'
12316        },
12317        separate_mixed_category: {
12318          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.",
12319          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'
12320        }
12321      };
12322
12323      var scanPresetInfo = {
12324        balanced: {
12325          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.",
12326          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
12327          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
12328          note: "Best when you want a stable local overview before making deeper adjustments.",
12329          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12330        },
12331        code_focused: {
12332          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
12333          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
12334          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
12335          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
12336          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12337        },
12338        comment_audit: {
12339          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
12340          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
12341          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
12342          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
12343          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12344        },
12345        deep_review: {
12346          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
12347          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
12348          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
12349          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
12350          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
12351        }
12352      };
12353
12354      var artifactPresetInfo = {
12355        review: {
12356          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.",
12357          chips: ["HTML", "PDF"],
12358          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
12359        },
12360        full: {
12361          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.",
12362          chips: ["HTML", "PDF", "JSON"],
12363          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
12364        },
12365        html_only: {
12366          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.",
12367          chips: ["HTML only", "Fast local review"],
12368          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
12369        },
12370        machine: {
12371          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
12372          chips: ["HTML", "JSON"],
12373          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
12374        }
12375      };
12376
12377      function applyTheme(theme) {
12378        if (theme === "dark") document.body.classList.add("dark-theme");
12379        else document.body.classList.remove("dark-theme");
12380      }
12381
12382      function loadSavedTheme() {
12383        var saved = null;
12384        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
12385        applyTheme(saved === "dark" ? "dark" : "light");
12386      }
12387
12388      function updateScrollProgress() {
12389        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
12390        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
12391        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
12392        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
12393        var step = Math.min(Math.max(currentStep, 1), 4);
12394        var base = stepBase[step];
12395        var end  = stepEnd[step];
12396
12397        var scrollFrac = 0;
12398        var activePanel = document.querySelector(".wizard-step.active");
12399        if (activePanel) {
12400          var scrollTop = window.scrollY || window.pageYOffset || 0;
12401          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
12402          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
12403          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
12404          var scrolled = scrollTop + viewH - panelTop;
12405          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
12406        }
12407
12408        var percent = Math.round(base + (end - base) * scrollFrac);
12409        percent = Math.min(end, Math.max(base, percent));
12410        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
12411        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
12412      }
12413
12414      function updateWizardProgress() {
12415        updateScrollProgress();
12416      }
12417
12418      var stepDescriptions = [
12419        "Choose a project folder, apply scope filters, and preview which files will be counted.",
12420        "Configure how mixed code-plus-comment lines and docstrings are classified.",
12421        "Pick your output formats, scan preset, and where reports are saved.",
12422        "Review all settings and launch the analysis."
12423      ];
12424
12425      function updateStepNav(step) {
12426        var infoLabel = document.getElementById("step-nav-info-label");
12427        var infoDesc  = document.getElementById("step-nav-info-desc");
12428        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
12429        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
12430      }
12431
12432      function updateSidebarSummary() {
12433        var sumPath    = document.getElementById("sum-path");
12434        var sumPreset  = document.getElementById("sum-preset");
12435        var sumOutput  = document.getElementById("sum-output");
12436        var sidebarSummary = document.getElementById("sidebar-summary");
12437        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
12438        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
12439        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
12440        if (sumPath)   sumPath.textContent   = pathVal   || "—";
12441        if (sumPreset) sumPreset.textContent = presetVal || "—";
12442        if (sumOutput) sumOutput.textContent = outputVal || "—";
12443        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
12444      }
12445
12446      function setStep(step, pushHistory) {
12447        currentStep = step;
12448        stepPanels.forEach(function (panel) {
12449          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
12450        });
12451        stepButtons.forEach(function (button) {
12452          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
12453        });
12454        var layoutEl = document.querySelector(".layout");
12455        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
12456        updateWizardProgress();
12457        updateStepNav(step);
12458        stepButtons.forEach(function(btn) {
12459          var t = Number(btn.getAttribute("data-step-target"));
12460          btn.classList.toggle("done", t < step);
12461        });
12462        updateSidebarSummary();
12463
12464        if (pushHistory !== false) {
12465          try {
12466            history.pushState({ wizardStep: step }, "", "#step" + step);
12467          } catch (e) {}
12468        }
12469
12470        window.scrollTo({ top: 0, behavior: "instant" });
12471      }
12472
12473      window.addEventListener("popstate", function (e) {
12474        if (e.state && e.state.wizardStep) {
12475          setStep(e.state.wizardStep, false);
12476        } else {
12477          var hashMatch = location.hash.match(/^#step([1-4])$/);
12478          if (hashMatch) setStep(Number(hashMatch[1]), false);
12479        }
12480      });
12481
12482      function inferTitleFromPath(value) {
12483        if (!value) return "project";
12484        var cleaned = value.replace(/[\/\\]+$/, "");
12485        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
12486        return parts.length ? parts[parts.length - 1] : value;
12487      }
12488
12489      function updateReportTitleFromPath() {
12490        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
12491        if (!reportTitleTouched) {
12492          reportTitleInput.value = inferred;
12493        }
12494        var title = reportTitleInput.value || inferred;
12495        if (liveReportTitle) liveReportTitle.textContent = title;
12496        if (reportTitlePreview) reportTitlePreview.textContent = title;
12497        document.title = "OxideSLOC | " + title;
12498
12499        var projectPath = (pathInput.value || "").trim();
12500        if (navProjectPill && navProjectTitle) {
12501          if (projectPath.length > 0) {
12502            navProjectTitle.textContent = inferred;
12503            navProjectPill.classList.add("visible");
12504          } else {
12505            navProjectTitle.textContent = "";
12506            navProjectPill.classList.remove("visible");
12507          }
12508        }
12509      }
12510
12511      function updateMixedPolicyUI() {
12512        var key = mixedLinePolicy.value || "code_only";
12513        var info = mixedPolicyInfo[key];
12514        document.getElementById("mixed-policy-description").textContent = info.description;
12515        document.getElementById("mixed-policy-example").textContent = info.example;
12516      }
12517
12518      function updatePythonDocstringUI() {
12519        var checked = !!pythonDocstrings.checked;
12520        document.getElementById("python-docstring-example").textContent = checked
12521          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
12522          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
12523        document.getElementById("python-docstring-live-help").textContent = checked
12524          ? "Enabled: docstrings contribute to comment-style totals."
12525          : "Disabled: docstrings are not counted as comment content.";
12526      }
12527
12528      function renderPresetChips(targetId, chips) {
12529        var target = document.getElementById(targetId);
12530        if (!target) return;
12531        target.innerHTML = (chips || []).map(function (chip) {
12532          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
12533        }).join('');
12534      }
12535
12536      function updatePresetDescriptions() {
12537        var scanInfo = scanPresetInfo[scanPreset.value];
12538        var artifactInfo = artifactPresetInfo[artifactPreset.value];
12539        document.getElementById("scan-preset-description").textContent = scanInfo.description;
12540        document.getElementById("scan-preset-example").textContent = scanInfo.example;
12541        document.getElementById("scan-preset-note").textContent = scanInfo.note;
12542        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
12543        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
12544        renderPresetChips("scan-preset-summary", scanInfo.chips);
12545        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
12546      }
12547
12548      function applyScanPreset() {
12549        var info = scanPresetInfo[scanPreset.value];
12550        if (!info || !info.apply) return;
12551        mixedLinePolicy.value = info.apply.mixed;
12552        pythonDocstrings.checked = !!info.apply.docstrings;
12553        document.getElementById("generated_file_detection").value = info.apply.generated;
12554        document.getElementById("minified_file_detection").value = info.apply.minified;
12555        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
12556        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
12557        document.getElementById("binary_file_behavior").value = info.apply.binary;
12558        updateMixedPolicyUI();
12559        updatePythonDocstringUI();
12560      }
12561
12562      function applyArtifactPreset() {
12563        var enabled = { html: false, pdf: false };
12564        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
12565        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
12566        if (artifactPreset.value === "html_only") { enabled.html = true; }
12567        if (artifactPreset.value === "machine") { enabled.html = true; }
12568
12569        artifactCards.forEach(function (card) {
12570          var artifact = card.getAttribute("data-artifact");
12571          if (artifact === "json") return;
12572          var checked = !!enabled[artifact];
12573          var checkbox = card.querySelector(".artifact-checkbox");
12574          checkbox.checked = checked;
12575          card.classList.toggle("selected", checked);
12576        });
12577      }
12578
12579      function toggleArtifactCard(card) {
12580        var checkbox = card.querySelector(".artifact-checkbox");
12581        checkbox.checked = !checkbox.checked;
12582        card.classList.toggle("selected", checkbox.checked);
12583      }
12584
12585      function updateReview() {
12586        var scanSummary = document.getElementById("review-scan-summary");
12587        var countSummary = document.getElementById("review-count-summary");
12588        var artifactSummary = document.getElementById("review-artifact-summary");
12589        var outputSummary = document.getElementById("review-output-summary");
12590        var previewSummary = document.getElementById("review-preview-summary");
12591        var readinessSummary = document.getElementById("review-readiness-summary");
12592        var includeText = document.getElementById("include_globs").value.trim();
12593        var excludeText = document.getElementById("exclude_globs").value.trim();
12594        var sidePathPreview = document.getElementById("side-path-preview");
12595        var sideOutputPreview = document.getElementById("side-output-preview");
12596        var sideTitlePreview = document.getElementById("side-title-preview");
12597
12598        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
12599        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
12600        if (sideTitlePreview) {
12601          var rt = document.getElementById("report_title");
12602          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
12603        }
12604
12605        scanSummary.innerHTML = ""
12606          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
12607          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
12608          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
12609
12610        countSummary.innerHTML = ""
12611          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
12612          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
12613          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
12614          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
12615          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
12616          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
12617          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
12618          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
12619
12620        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
12621        artifactSummary.innerHTML = ""
12622          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
12623          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
12624
12625        outputSummary.innerHTML = ""
12626          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
12627          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
12628
12629        if (previewSummary) {
12630          if (GIT_MODE) {
12631            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>';
12632          } else {
12633          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
12634          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
12635          var statMap = {};
12636          statButtons.forEach(function (button) {
12637            var valueNode = button.querySelector('.scope-stat-value');
12638            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
12639          });
12640          previewSummary.innerHTML = ''
12641            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
12642            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
12643            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
12644            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
12645            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
12646            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
12647
12648          if (readinessSummary) {
12649            var selectedArtifactsCount = selectedArtifacts.length;
12650            readinessSummary.innerHTML = ''
12651              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
12652              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
12653              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
12654              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
12655          }
12656          } // end else (non-GIT_MODE)
12657        }
12658      }
12659
12660      function escapeHtml(value) {
12661        return String(value)
12662          .replace(/&/g, "&amp;")
12663          .replace(/</g, "&lt;")
12664          .replace(/>/g, "&gt;")
12665          .replace(/"/g, "&quot;")
12666          .replace(/'/g, "&#39;");
12667      }
12668
12669      function isPythonVisible() {
12670        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
12671      }
12672
12673      function syncPythonVisibility() {
12674        var html = previewPanel.textContent || "";
12675        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
12676        pythonWraps.forEach(function (node) {
12677          node.classList.toggle("hidden", !hasPython);
12678        });
12679      }
12680
12681      function attachPreviewInteractions() {
12682        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
12683        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
12684        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
12685        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
12686        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
12687        var searchInput = previewPanel.querySelector("#explorer-search");
12688        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
12689        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
12690        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
12691        var activeFilter = "all";
12692        var activeLanguage = "";
12693        var searchTerm = "";
12694        var currentSortKey = null;
12695        var currentSortOrder = "asc";
12696        var childRows = {};
12697
12698        rows.forEach(function (row) {
12699          var parentId = row.getAttribute("data-parent-id") || "";
12700          var rowId = row.getAttribute("data-row-id") || "";
12701          if (!childRows[parentId]) childRows[parentId] = [];
12702          childRows[parentId].push(rowId);
12703        });
12704
12705        function rowById(id) {
12706          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
12707        }
12708
12709        function hasCollapsedAncestor(row) {
12710          var parentId = row.getAttribute("data-parent-id");
12711          while (parentId) {
12712            var parent = rowById(parentId);
12713            if (!parent) break;
12714            if (parent.getAttribute("data-expanded") === "false") return true;
12715            parentId = parent.getAttribute("data-parent-id");
12716          }
12717          return false;
12718        }
12719
12720        function updateToggleGlyph(row) {
12721          var toggle = row.querySelector(".tree-toggle");
12722          if (!toggle) return;
12723          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
12724        }
12725
12726        function rowSortValue(row, key) {
12727          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
12728        }
12729
12730        function updateSortButtons() {
12731          sortButtons.forEach(function (button) {
12732            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
12733            var indicator = button.querySelector(".tree-sort-indicator");
12734            button.classList.toggle("active", isActive);
12735            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
12736            if (indicator) {
12737              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
12738            }
12739          });
12740        }
12741
12742        function sortSiblingRows() {
12743          if (!treeContainer) {
12744            updateSortButtons();
12745            return;
12746          }
12747
12748          var rowMap = {};
12749          var childrenMap = {};
12750          rows.forEach(function (row) {
12751            var rowId = row.getAttribute("data-row-id");
12752            var parentId = row.getAttribute("data-parent-id") || "";
12753            rowMap[rowId] = row;
12754            if (!childrenMap[parentId]) childrenMap[parentId] = [];
12755            childrenMap[parentId].push(rowId);
12756          });
12757
12758          Object.keys(childrenMap).forEach(function (parentId) {
12759            if (!parentId) return;
12760            childrenMap[parentId].sort(function (a, b) {
12761              var rowA = rowMap[a];
12762              var rowB = rowMap[b];
12763              if (!currentSortKey) {
12764                return Number(a) - Number(b);
12765              }
12766              var valueA = rowSortValue(rowA, currentSortKey);
12767              var valueB = rowSortValue(rowB, currentSortKey);
12768              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
12769              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
12770              var fallbackA = rowSortValue(rowA, "name");
12771              var fallbackB = rowSortValue(rowB, "name");
12772              if (fallbackA < fallbackB) return -1;
12773              if (fallbackA > fallbackB) return 1;
12774              return Number(a) - Number(b);
12775            });
12776          });
12777
12778          var orderedIds = [];
12779          function pushChildren(parentId) {
12780            (childrenMap[parentId] || []).forEach(function (childId) {
12781              orderedIds.push(childId);
12782              pushChildren(childId);
12783            });
12784          }
12785
12786          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
12787            orderedIds.push(topId);
12788            pushChildren(topId);
12789          });
12790
12791          orderedIds.forEach(function (id) {
12792            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
12793          });
12794          updateSortButtons();
12795        }
12796
12797        function updateLanguageButtons() {
12798          languageButtons.forEach(function (button) {
12799            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
12800            var isActive = languageValue === activeLanguage;
12801            button.classList.toggle("active", isActive);
12802          });
12803        }
12804
12805        function rowSelfMatches(row) {
12806          var kind = row.getAttribute("data-kind");
12807          var status = row.getAttribute("data-status");
12808          var language = (row.getAttribute("data-language") || "").toLowerCase();
12809          var name = row.getAttribute("data-name-lower") || "";
12810          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
12811          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
12812          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
12813          var passesLanguage = !activeLanguage || language === activeLanguage;
12814          return passesFilter && passesSearch && passesLanguage;
12815        }
12816
12817        function hasMatchingDescendant(rowId) {
12818          return (childRows[rowId] || []).some(function (childId) {
12819            var childRow = rowById(childId);
12820            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
12821          });
12822        }
12823
12824        function rowMatches(row) {
12825          if (rowSelfMatches(row)) return true;
12826          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
12827        }
12828
12829        function resetViewState() {
12830          activeFilter = "all";
12831          activeLanguage = "";
12832          searchTerm = "";
12833          currentSortKey = null;
12834          currentSortOrder = "asc";
12835          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
12836          if (searchInput) searchInput.value = "";
12837          if (filterSelect) filterSelect.value = "all";
12838          updateLanguageButtons();
12839        }
12840
12841        function applyVisibility() {
12842          rows.forEach(function (row) {
12843            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
12844            row.classList.toggle("hidden-by-filter", !visible);
12845            row.style.display = visible ? "grid" : "none";
12846          });
12847          buttons.forEach(function (button) {
12848            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
12849          });
12850          if (filterSelect) filterSelect.value = activeFilter;
12851        }
12852
12853        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
12854        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
12855        var originalStats = {};
12856        buttons.forEach(function (btn) {
12857          var f = btn.getAttribute('data-filter');
12858          var v = btn.querySelector('.scope-stat-value');
12859          if (f && v) originalStats[f] = v.textContent;
12860        });
12861
12862        function applySubmoduleStats(statsJson) {
12863          try {
12864            var s = JSON.parse(statsJson);
12865            buttons.forEach(function (btn) {
12866              var f = btn.getAttribute('data-filter');
12867              var v = btn.querySelector('.scope-stat-value');
12868              if (!v) return;
12869              if (f === 'dir') v.textContent = s.dirs;
12870              else if (f === 'file') v.textContent = s.files;
12871              else if (f === 'supported') v.textContent = s.supported;
12872              else if (f === 'skipped') v.textContent = s.skipped;
12873              else if (f === 'unsupported') v.textContent = s.unsupported;
12874            });
12875          } catch (e) {}
12876        }
12877
12878        function restoreBaseRepoStats() {
12879          buttons.forEach(function (btn) {
12880            var f = btn.getAttribute('data-filter');
12881            var v = btn.querySelector('.scope-stat-value');
12882            if (v && originalStats[f]) v.textContent = originalStats[f];
12883          });
12884          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
12885          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
12886        }
12887
12888        submoduleChips.forEach(function (chip) {
12889          chip.addEventListener('click', function () {
12890            var statsJson = chip.getAttribute('data-sub-stats');
12891            if (!statsJson) return;
12892            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
12893            chip.classList.add('active');
12894            applySubmoduleStats(statsJson);
12895            if (baseRepoBtn) baseRepoBtn.style.display = '';
12896          });
12897        });
12898
12899        if (baseRepoBtn) {
12900          baseRepoBtn.addEventListener('click', function () {
12901            restoreBaseRepoStats();
12902            resetViewState();
12903            sortSiblingRows();
12904            applyVisibility();
12905          });
12906        }
12907
12908        buttons.forEach(function (button) {
12909          button.addEventListener("click", function () {
12910            var filterValue = button.getAttribute("data-filter") || "all";
12911            if (filterValue === "reset-view") {
12912              restoreBaseRepoStats();
12913              resetViewState();
12914              sortSiblingRows();
12915              applyVisibility();
12916              return;
12917            }
12918            activeFilter = filterValue;
12919            applyVisibility();
12920          });
12921        });
12922
12923        rows.forEach(function (row) {
12924          updateToggleGlyph(row);
12925          var toggle = row.querySelector(".tree-toggle");
12926          if (toggle) {
12927            toggle.addEventListener("click", function () {
12928              var expanded = row.getAttribute("data-expanded") !== "false";
12929              row.setAttribute("data-expanded", expanded ? "false" : "true");
12930              updateToggleGlyph(row);
12931              applyVisibility();
12932            });
12933          }
12934        });
12935
12936        actionButtons.forEach(function (button) {
12937          button.addEventListener("click", function () {
12938            var action = button.getAttribute("data-explorer-action");
12939            if (action === "expand-all") {
12940              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
12941            } else if (action === "collapse-all") {
12942              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
12943            } else if (action === "clear-filters") {
12944              resetViewState();
12945            }
12946            sortSiblingRows();
12947            applyVisibility();
12948          });
12949        });
12950
12951        if (filterSelect) {
12952          filterSelect.addEventListener("change", function () {
12953            activeFilter = filterSelect.value || "all";
12954            applyVisibility();
12955          });
12956        }
12957
12958        languageButtons.forEach(function (button) {
12959          button.addEventListener("click", function () {
12960            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
12961            updateLanguageButtons();
12962            applyVisibility();
12963          });
12964        });
12965
12966        sortButtons.forEach(function (button) {
12967          button.addEventListener("click", function () {
12968            var sortKey = button.getAttribute("data-sort-key");
12969            if (currentSortKey === sortKey) {
12970              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
12971            } else {
12972              currentSortKey = sortKey;
12973              currentSortOrder = "asc";
12974            }
12975            sortSiblingRows();
12976            applyVisibility();
12977          });
12978        });
12979
12980        if (searchInput) {
12981          searchInput.addEventListener("input", function () {
12982            searchTerm = searchInput.value.trim().toLowerCase();
12983            applyVisibility();
12984          });
12985        }
12986
12987        updateLanguageButtons();
12988        sortSiblingRows();
12989        applyVisibility();
12990      }
12991
12992      function loadPreview() {
12993        if (!previewPanel || !pathInput) return;
12994        if (GIT_MODE) {
12995          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>';
12996          return;
12997        }
12998        var path = pathInput.value.trim();
12999        var zeroWarn = document.getElementById('zero-files-warning');
13000        if (!path) {
13001          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
13002          if (zeroWarn) zeroWarn.style.display = 'none';
13003          return;
13004        }
13005        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
13006        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
13007        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
13008        var previewUrl = "/preview?path=" + encodeURIComponent(path)
13009          + "&include_globs=" + encodeURIComponent(includeValue)
13010          + "&exclude_globs=" + encodeURIComponent(excludeValue);
13011        fetch(previewUrl)
13012          .then(function (response) { return response.text(); })
13013          .then(function (html) {
13014            previewPanel.innerHTML = html;
13015            attachPreviewInteractions();
13016            syncPythonVisibility();
13017            updateReview();
13018            setTimeout(collapseLanguagePills, 50);
13019            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
13020            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
13021            var sizeText = document.getElementById('project-size-text');
13022            var sizeBtn = document.getElementById('project-size-btn');
13023            // In server mode with upload sizes available, keep the compressed/original pair.
13024            if (SERVER_MODE && window._lastUploadSizes) {
13025              var us = window._lastUploadSizes;
13026              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
13027                ' · Compressed: ' + fmtBytes(us.compressed_bytes);
13028              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
13029                ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
13030            } else if (sizeText && projectSize) {
13031              sizeText.textContent = 'Project size: ' + projectSize;
13032              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
13033            } else if (sizeText) {
13034              sizeText.textContent = 'Project size: —';
13035            }
13036            if (zeroWarn) {
13037              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
13038              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
13039              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
13040              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
13041              if (supportedCount === 0 && fileCount > 0) {
13042                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).';
13043                zeroWarn.style.display = '';
13044              } else {
13045                zeroWarn.style.display = 'none';
13046              }
13047            }
13048          })
13049          .catch(function (err) {
13050            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
13051          });
13052      }
13053
13054      function pickDirectory(targetInput, kind) {
13055        if (SERVER_MODE) {
13056          if (kind === 'output') {
13057            showBannerToast(
13058              'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
13059              false,
13060              { top: true, icon: '📁' }
13061            );
13062            return;
13063          }
13064          var inputEl = kind === 'coverage'
13065            ? document.getElementById('cov-upload-input')
13066            : document.getElementById('dir-upload-input');
13067          if (!inputEl) return;
13068          inputEl.onchange = function () {
13069            var files = inputEl.files;
13070            if (!files || files.length === 0) return;
13071            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
13072            if (browseBtn) browseBtn.disabled = true;
13073
13074            function fileToBase64(file) {
13075              return new Promise(function (resolve, reject) {
13076                var reader = new FileReader();
13077                reader.onload = function () {
13078                  var b64 = reader.result.split(',')[1];
13079                  resolve(b64);
13080                };
13081                reader.onerror = reject;
13082                reader.readAsDataURL(file);
13083              });
13084            }
13085
13086            if (kind === 'coverage') {
13087              var f = files[0];
13088              if (previewPanel && targetInput === pathInput)
13089                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
13090              fileToBase64(f).then(function (b64) {
13091                return fetch('/api/upload-file', {
13092                  method: 'POST',
13093                  headers: { 'Content-Type': 'application/json' },
13094                  body: JSON.stringify({ filename: f.name, content: b64 })
13095                }).then(function (r) { return r.json(); });
13096              })
13097                .then(function (d) {
13098                  if (d && d.tmp_path) {
13099                    if (coverageInput) coverageInput.value = d.tmp_path;
13100                    setCovStatus('idle');
13101                  } else if (d && d.error) { showBannerToast(d.error, true); }
13102                })
13103                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
13104                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
13105            } else {
13106              // ── Filter to source-code files only ─────────────────────────
13107              // Binary, generated, and dependency files (node_modules, .git,
13108              // build artifacts) are skipped so they are never uploaded.
13109              var CODE_EXTS = new Set([
13110                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13111                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13112                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13113                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13114                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13115                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
13116                'tf','hcl','proto','thrift','avsc','graphql','gql'
13117              ]);
13118              var codeFiles = [];
13119              for (var i = 0; i < files.length; i++) {
13120                var f = files[i];
13121                var name = f.name;
13122                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
13123                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
13124                  codeFiles.push(f); continue;
13125                }
13126                var dot = name.lastIndexOf('.');
13127                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
13128              }
13129              // Collect specific .git metadata files for server-side git detection.
13130              // These have no source extension so they are excluded by the loop above,
13131              // but the server needs them to read branch/commit/author without running git.
13132              var gitMetaFiles = [];
13133              for (var i = 0; i < files.length; i++) {
13134                var f = files[i];
13135                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
13136                var gitIdx = rp.indexOf('/.git/');
13137                if (gitIdx < 0) continue;
13138                var gitRel = rp.slice(gitIdx + 1);
13139                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
13140                    gitRel === '.git/logs/HEAD' ||
13141                    gitRel.startsWith('.git/refs/heads/') ||
13142                    gitRel.startsWith('.git/refs/tags/')) {
13143                  gitMetaFiles.push(f);
13144                }
13145              }
13146              var uploadFiles = codeFiles.concat(gitMetaFiles);
13147              var total = files.length;
13148              var kept = codeFiles.length;
13149              if (kept === 0) {
13150                if (previewPanel && targetInput === pathInput)
13151                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
13152                if (browseBtn) browseBtn.disabled = false;
13153                inputEl.value = '';
13154                return;
13155              }
13156
13157              // ── Helper: apply upload result to UI ────────────────────────
13158              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
13159              function applyUploadResult(tmpPath, sizes) {
13160                targetInput.value = tmpPath;
13161                scrollInputToEnd(targetInput);
13162                if (sizes && SERVER_MODE) {
13163                  window._lastUploadSizes = sizes;
13164                  // Immediately show both sizes before preview loads.
13165                  var sizeText = document.getElementById('project-size-text');
13166                  var sizeBtn = document.getElementById('project-size-btn');
13167                  if (sizeText) {
13168                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13169                      ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13170                  }
13171                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13172                    ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13173                }
13174                if (targetInput === pathInput) {
13175                  updateReportTitleFromPath();
13176                  autoSetOutputDir(tmpPath);
13177                  fetchProjectHistory(tmpPath);
13178                  loadPreview();
13179                  suggestCoverageFile(tmpPath);
13180                }
13181                updateReview();
13182                if (browseBtn) browseBtn.disabled = false;
13183                inputEl.value = '';
13184              }
13185
13186              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
13187              if (typeof CompressionStream !== 'undefined') {
13188                if (previewPanel && targetInput === pathInput)
13189                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13190
13191                // Build a minimal POSIX ustar tar header for a single file entry.
13192                function buildUstarHeader(filePath, fileSize) {
13193                  var BLOCK = 512;
13194                  var hdr = new Uint8Array(BLOCK);
13195                  var enc = new TextEncoder();
13196                  function wStr(off, len, s) {
13197                    var b = enc.encode(s);
13198                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
13199                  }
13200                  function wOct(off, len, val) {
13201                    var s = val.toString(8);
13202                    while (s.length < len - 1) s = '0' + s;
13203                    wStr(off, len, s + '\0');
13204                  }
13205                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
13206                  var name = filePath, prefix = '';
13207                  if (filePath.length > 99) {
13208                    var split = filePath.lastIndexOf('/', 154);
13209                    if (split > 0 && filePath.length - split - 1 <= 99) {
13210                      prefix = filePath.substring(0, split);
13211                      name   = filePath.substring(split + 1);
13212                    } else { name = filePath.substring(0, 99); }
13213                  }
13214                  wStr(0,   100, name);          // name
13215                  wOct(100,   8, 0o000644);      // mode
13216                  wOct(108,   8, 0);             // uid
13217                  wOct(116,   8, 0);             // gid
13218                  wOct(124,  12, fileSize);      // size
13219                  wOct(136,  12, 0);             // mtime (epoch)
13220                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
13221                  hdr[156] = 48;                 // type flag '0' = regular file
13222                  wStr(157, 100, '');            // linkname
13223                  wStr(257,   6, 'ustar');       // magic
13224                  wStr(263,   2, '00');          // version
13225                  wStr(265,  32, '');            // uname
13226                  wStr(297,  32, '');            // gname
13227                  wOct(329,   8, 0);             // devmajor
13228                  wOct(337,   8, 0);             // devminor
13229                  wStr(345, 155, prefix);        // prefix
13230                  // Compute checksum (sum of all bytes, placeholder = 32).
13231                  var chk = 0;
13232                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13233                  var cs = chk.toString(8);
13234                  while (cs.length < 6) cs = '0' + cs;
13235                  wStr(148, 8, cs + '\0 ');
13236                  return hdr;
13237                }
13238
13239                // Build tar.gz one file at a time, piping through CompressionStream.
13240                // RAM usage = compressed output buffer + one file at a time.
13241                (async function () {
13242                  try {
13243                    var BLOCK = 512;
13244                    var cs     = new CompressionStream('gzip');
13245                    var writer = cs.writable.getWriter();
13246                    var chunks = [];
13247                    var reader = cs.readable.getReader();
13248                    var collecting = (async function () {
13249                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
13250                    })();
13251
13252                    for (var i = 0; i < uploadFiles.length; i++) {
13253                      var file = uploadFiles[i];
13254                      var path = file.webkitRelativePath || file.name;
13255                      var buf  = await file.arrayBuffer();
13256                      var data = new Uint8Array(buf);
13257                      // Header block
13258                      await writer.write(buildUstarHeader(path, data.length));
13259                      // Data padded to 512-byte boundary
13260                      if (data.length > 0) {
13261                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
13262                        var block  = new Uint8Array(padded);
13263                        block.set(data);
13264                        await writer.write(block);
13265                      }
13266                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
13267                        if (previewPanel && targetInput === pathInput)
13268                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13269                      }
13270                    }
13271                    // End-of-archive: two 512-byte zero blocks
13272                    await writer.write(new Uint8Array(BLOCK * 2));
13273                    await writer.close();
13274                    await collecting;
13275
13276                    var blob = new Blob(chunks, { type: 'application/gzip' });
13277                    var sizeMB = (blob.size / 1048576).toFixed(1);
13278                    if (previewPanel && targetInput === pathInput)
13279                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
13280
13281                    var resp = await fetch('/api/upload-tarball', {
13282                      method: 'POST',
13283                      headers: { 'Content-Type': 'application/gzip' },
13284                      body: blob
13285                    });
13286                    var d = await resp.json();
13287                    if (d && d.tmp_path) {
13288                      applyUploadResult(d.tmp_path, {
13289                        compressed_bytes: d.compressed_bytes || 0,
13290                        original_bytes: d.original_bytes || 0
13291                      });
13292                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13293                  } catch (e) {
13294                    showBannerToast('Upload failed: ' + String(e), true);
13295                    if (browseBtn) browseBtn.disabled = false;
13296                    inputEl.value = '';
13297                  }
13298                })();
13299
13300              } else {
13301                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
13302                // Used only on browsers that lack CompressionStream (pre-2023).
13303                var BATCH = 200;
13304                var batches = [];
13305                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
13306                var totalBatches = batches.length;
13307                if (previewPanel && targetInput === pathInput)
13308                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
13309
13310                function sendBatch(idx, currentUploadId, lastTmpPath) {
13311                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
13312                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
13313                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
13314                  Promise.all(batches[idx].map(function (file) {
13315                    return fileToBase64(file).then(function (b64) {
13316                      return { path: file.webkitRelativePath || file.name, content: b64 };
13317                    });
13318                  })).then(function (fileList) {
13319                    var body = { files: fileList };
13320                    if (currentUploadId) body.upload_id = currentUploadId;
13321                    return fetch('/api/upload-directory', {
13322                      method: 'POST', headers: { 'Content-Type': 'application/json' },
13323                      body: JSON.stringify(body)
13324                    }).then(function (r) { return r.json(); });
13325                  }).then(function (d) {
13326                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
13327                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13328                  }).catch(function (e) {
13329                    showBannerToast('Upload failed: ' + String(e), true);
13330                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
13331                  });
13332                }
13333                sendBatch(0, null, '');
13334              }
13335            }
13336          };
13337          inputEl.click();
13338          return;
13339        }
13340
13341        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
13342        if (browseButton) browseButton.disabled = true;
13343
13344        if (previewPanel && targetInput === pathInput) {
13345          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
13346        }
13347
13348        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
13349          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
13350          .then(function (data) {
13351            if (data && data.selected_path) {
13352              targetInput.value = data.selected_path;
13353              scrollInputToEnd(targetInput);
13354
13355              if (targetInput === pathInput) {
13356                updateReportTitleFromPath();
13357                autoSetOutputDir(data.selected_path);
13358                fetchProjectHistory(data.selected_path);
13359                loadPreview();
13360                suggestCoverageFile(data.selected_path);
13361              }
13362
13363              updateReview();
13364            } else if (targetInput === pathInput) {
13365              loadPreview();
13366            }
13367          })
13368          .catch(function () {
13369            window.alert("Directory picker request failed.");
13370            if (previewPanel && targetInput === pathInput) {
13371              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
13372            }
13373          })
13374          .finally(function () {
13375            if (browseButton) browseButton.disabled = false;
13376          });
13377      }
13378
13379      if (themeToggle) {
13380        themeToggle.addEventListener("click", function () {
13381          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
13382          applyTheme(nextTheme);
13383          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
13384        });
13385      }
13386
13387      stepButtons.forEach(function (button) {
13388        button.addEventListener("click", function () {
13389          setStep(Number(button.getAttribute("data-step-target")));
13390        });
13391      });
13392
13393      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
13394        button.addEventListener("click", function () {
13395          setStep(Number(button.getAttribute("data-step-target")) || 1);
13396        });
13397      });
13398
13399      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
13400        button.addEventListener("click", function () {
13401          updateReview();
13402          setStep(Number(button.getAttribute("data-next")));
13403        });
13404      });
13405
13406      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
13407        button.addEventListener("click", function () {
13408          setStep(Number(button.getAttribute("data-prev")));
13409        });
13410      });
13411
13412      document.addEventListener("keydown", function (e) {
13413        var tag = (document.activeElement || {}).tagName || "";
13414        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
13415        if (e.altKey || e.ctrlKey || e.metaKey) return;
13416        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
13417        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
13418      });
13419
13420      if (useSamplePath) {
13421        useSamplePath.addEventListener("click", function () {
13422          pathInput.value = "tests/fixtures/basic";
13423          updateReportTitleFromPath();
13424          autoSetOutputDir("tests/fixtures/basic");
13425          loadPreview();
13426          suggestCoverageFile("tests/fixtures/basic");
13427        });
13428      }
13429
13430      if (useDefaultOutput) {
13431        useDefaultOutput.addEventListener("click", function () {
13432          delete outputDirInput.dataset.userEdited;
13433          autoSetOutputDir(pathInput ? pathInput.value : "");
13434          updateReview();
13435        });
13436      }
13437
13438      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
13439      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
13440
13441      // ── Drag-and-drop directory upload (server mode only) ─────────────────
13442      // Dropping a folder onto the path field bypasses Chrome's
13443      // "Upload X files to this site?" confirmation dialog.
13444      async function readDirRecursively(dirEntry, basePath) {
13445        var reader = dirEntry.createReader();
13446        var all = [];
13447        for (;;) {
13448          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
13449          if (!batch.length) break;
13450          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
13451        }
13452        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
13453        var out = [];
13454        for (var i = 0; i < all.length; i++) {
13455          var sub = all[i];
13456          if (sub.isFile) {
13457            var f = await new Promise(function(res) { sub.file(res); });
13458            out.push({ file: f, path: basePath + '/' + sub.name });
13459          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
13460            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
13461            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
13462          }
13463        }
13464        return out;
13465      }
13466
13467      function setupPathDropZone() {
13468        if (!SERVER_MODE || !pathInput) return;
13469        var CODE_EXTS = new Set([
13470          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13471          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13472          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13473          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13474          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13475          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
13476        ]);
13477        pathInput.addEventListener('dragover', function(e) {
13478          e.preventDefault();
13479          pathInput.classList.add('drag-over');
13480        });
13481        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
13482        pathInput.addEventListener('drop', function(e) {
13483          e.preventDefault();
13484          pathInput.classList.remove('drag-over');
13485          var items = e.dataTransfer.items;
13486          if (!items || !items.length) return;
13487          var dirEntry = null;
13488          for (var i = 0; i < items.length; i++) {
13489            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
13490            if (entry && entry.isDirectory) { dirEntry = entry; break; }
13491          }
13492          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
13493          var btn = browsePath;
13494          if (btn) btn.disabled = true;
13495          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
13496
13497          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
13498            var total = allEntries.length;
13499            var codeEntries = allEntries.filter(function(e) {
13500              var n = e.file.name;
13501              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
13502              var dot = n.lastIndexOf('.');
13503              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
13504            });
13505            var kept = codeEntries.length;
13506            if (kept === 0) {
13507              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
13508              if (btn) btn.disabled = false; return;
13509            }
13510
13511            function finish(tmpPath, sizes) {
13512              pathInput.value = tmpPath;
13513              scrollInputToEnd(pathInput);
13514              if (sizes) {
13515                window._lastUploadSizes = sizes;
13516                var sizeText = document.getElementById('project-size-text');
13517                var sizeBtn = document.getElementById('project-size-btn');
13518                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13519                  ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13520                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13521                  ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13522              }
13523              updateReportTitleFromPath();
13524              autoSetOutputDir(tmpPath);
13525              fetchProjectHistory(tmpPath);
13526              loadPreview();
13527              suggestCoverageFile(tmpPath);
13528              updateReview();
13529              if (btn) btn.disabled = false;
13530            }
13531
13532            if (typeof CompressionStream === 'undefined') {
13533              showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
13534              if (btn) btn.disabled = false; return;
13535            }
13536
13537            try {
13538              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13539              var BLOCK = 512;
13540              var cs = new CompressionStream('gzip');
13541              var wtr = cs.writable.getWriter();
13542              var chunks = [];
13543              var rdr = cs.readable.getReader();
13544              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
13545
13546              function buildHdr(fp, sz) {
13547                var hdr = new Uint8Array(BLOCK);
13548                var enc = new TextEncoder();
13549                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]; }
13550                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
13551                var nm = fp, pfx = '';
13552                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); } }
13553                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
13554                for (var i = 148; i < 156; i++) hdr[i] = 32;
13555                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);
13556                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13557                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
13558                return hdr;
13559              }
13560
13561              for (var i = 0; i < codeEntries.length; i++) {
13562                var ce = codeEntries[i];
13563                var buf = await ce.file.arrayBuffer();
13564                var data = new Uint8Array(buf);
13565                await wtr.write(buildHdr(ce.path, data.length));
13566                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
13567                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
13568                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13569              }
13570              await wtr.write(new Uint8Array(BLOCK * 2));
13571              await wtr.close();
13572              await collecting;
13573
13574              var blob = new Blob(chunks, { type: 'application/gzip' });
13575              var sizeMB = (blob.size / 1048576).toFixed(1);
13576              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
13577              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
13578              var d = await resp.json();
13579              if (d && d.tmp_path) {
13580                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
13581              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
13582            } catch (err) {
13583              showBannerToast('Upload failed: ' + String(err), true);
13584              if (btn) btn.disabled = false;
13585            }
13586          }).catch(function(err) {
13587            showBannerToast('Could not read folder: ' + String(err), true);
13588            if (btn) btn.disabled = false;
13589          });
13590        });
13591      }
13592      setupPathDropZone();
13593      if (browseCoverage) {
13594        browseCoverage.addEventListener("click", function () {
13595          pickDirectory(coverageInput || pathInput, "coverage");
13596        });
13597      }
13598
13599      function setCovStatus(state, opts) {
13600        if (!covScanStatus) return;
13601        opts = opts || {};
13602        covScanStatus.className = "cov-scan-status cov-scan-" + state;
13603        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
13604        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>';
13605        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>';
13606        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>';
13607        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>';
13608        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
13609        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
13610        if (state === "scanning") {
13611          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
13612        } else if (state === "found") {
13613          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13614          html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
13615          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
13616          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
13617        } else if (state === "hint") {
13618          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13619          html += '<div class="cov-scan-title">' + tb2 + ' detected &mdash; no coverage file found yet</div>';
13620          html += '<div class="cov-scan-sub">Generate one with:</div>';
13621          html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
13622        } else if (state === "none") {
13623          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
13624          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
13625        }
13626        html += '</div></div>';
13627        covScanStatus.innerHTML = html;
13628        if (state === "found") {
13629          var useBtn = covScanStatus.querySelector(".cov-scan-use");
13630          if (useBtn) useBtn.addEventListener("click", function () {
13631            if (coverageInput) coverageInput.value = "";
13632            covAutoFilled = false;
13633            setCovStatus("idle");
13634          });
13635        }
13636      }
13637
13638      function suggestCoverageFile(projectPath) {
13639        if (!coverageInput || !covScanStatus) return;
13640        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
13641        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
13642        clearTimeout(coverageSuggestTimer);
13643        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
13644        setCovStatus("scanning");
13645        coverageSuggestTimer = setTimeout(function () {
13646          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
13647            .then(function (r) { return r.json(); })
13648            .then(function (d) {
13649              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
13650              if (!d) { setCovStatus("none"); return; }
13651              if (d.found) {
13652                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
13653                setCovStatus("found", { found: d.found, tool: d.tool });
13654              } else if (d.tool && d.hint) {
13655                setCovStatus("hint", { tool: d.tool, hint: d.hint });
13656              } else {
13657                setCovStatus("none");
13658              }
13659            })
13660            .catch(function () { setCovStatus("idle"); });
13661        }, 600);
13662      }
13663
13664      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
13665
13666      if (coverageInput) coverageInput.addEventListener("input", function () {
13667        covAutoFilled = false;
13668        if (!this.value.trim()) setCovStatus("idle");
13669      });
13670
13671      // ── Language pill overflow: collapse to "+N more" chip ─────────────
13672      function collapseLanguagePills() {
13673        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
13674        rows.forEach(function(row) {
13675          // Remove any previous overflow chip
13676          var prev = row.querySelector('.lang-overflow-chip');
13677          if (prev) prev.remove();
13678          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
13679          pills.forEach(function(p) { p.style.display = ''; });
13680          if (!pills.length) return;
13681
13682          // Measure after restoring all pills
13683          var containerRight = row.getBoundingClientRect().right;
13684          var hidden = [];
13685          for (var i = pills.length - 1; i >= 1; i--) {
13686            var rect = pills[i].getBoundingClientRect();
13687            if (rect.right > containerRight + 2) {
13688              hidden.unshift(pills[i]);
13689              pills[i].style.display = 'none';
13690            } else {
13691              break;
13692            }
13693          }
13694
13695          if (hidden.length) {
13696            var chip = document.createElement('button');
13697            chip.type = 'button';
13698            chip.className = 'language-pill lang-overflow-chip';
13699            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
13700            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
13701            row.appendChild(chip);
13702          }
13703        });
13704      }
13705
13706      // Run after preview loads (preview panel populates language pills)
13707      var _origLoadPreviewCb = window.__previewLoaded;
13708      document.addEventListener('previewLoaded', collapseLanguagePills);
13709      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
13710      setTimeout(collapseLanguagePills, 400);
13711
13712      // ── Project history & output dir auto-set ──────────────────────────
13713      var wsOutputRoot   = document.getElementById("ws-output-root");
13714      var wsScanCount    = document.getElementById("ws-scan-count");
13715      var wsLastScan     = document.getElementById("ws-last-scan");
13716      var historyBadge   = document.getElementById("path-history-badge");
13717      var historyTimer   = null;
13718
13719      var wsOutputLink = document.getElementById("ws-output-link");
13720      function syncStripOutputRoot() {
13721        var val = outputDirInput ? outputDirInput.value : "";
13722        var display = val || "project/sloc";
13723        if (wsOutputRoot) wsOutputRoot.textContent = display;
13724        if (wsOutputLink) wsOutputLink.dataset.folder = val;
13725      }
13726
13727      function scrollInputToEnd(input) {
13728        if (!input) return;
13729        // Defer so the DOM has the new value before we measure scroll width.
13730        requestAnimationFrame(function () {
13731          input.scrollLeft = input.scrollWidth;
13732          input.selectionStart = input.selectionEnd = input.value.length;
13733        });
13734      }
13735
13736      function autoSetOutputDir(projectPath) {
13737        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
13738        if (GIT_MODE && GIT_OUTPUT_DIR) {
13739          outputDirInput.value = GIT_OUTPUT_DIR;
13740          scrollInputToEnd(outputDirInput);
13741          syncStripOutputRoot();
13742          updateReview();
13743          return;
13744        }
13745        if (!projectPath || !projectPath.trim()) return;
13746        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
13747        outputDirInput.value = cleaned + "/sloc";
13748        scrollInputToEnd(outputDirInput);
13749        syncStripOutputRoot();
13750        updateReview();
13751      }
13752
13753      var wsBranch = document.getElementById("ws-branch");
13754
13755      function fetchProjectHistory(projectPath) {
13756        if (!projectPath || !projectPath.trim()) {
13757          if (wsScanCount) wsScanCount.textContent = "—";
13758          if (wsLastScan)  wsLastScan.textContent  = "—";
13759          if (wsBranch)    wsBranch.textContent    = "—";
13760          if (historyBadge) historyBadge.style.display = "none";
13761          return;
13762        }
13763        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
13764          .then(function (r) { return r.ok ? r.json() : null; })
13765          .then(function (data) {
13766            if (!data) return;
13767            var countStr = data.scan_count > 0
13768              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
13769              : "never";
13770            var tsStr = data.last_scan_timestamp
13771              ? data.last_scan_timestamp.replace(" UTC","")
13772              : "—";
13773            if (wsScanCount) wsScanCount.textContent = countStr;
13774            if (wsLastScan)  wsLastScan.textContent  = tsStr;
13775            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
13776            if (data.scan_count > 0) {
13777              if (historyBadge) {
13778                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
13779                historyBadge.textContent = data.scan_count + " previous scan" +
13780                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
13781                  "Last: " + (data.last_scan_timestamp || "—") +
13782                  " — " + (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.";
13783                historyBadge.className = "path-history-badge found";
13784                historyBadge.style.display = "";
13785              }
13786            } else {
13787              if (historyBadge) historyBadge.style.display = "none";
13788            }
13789          })
13790          .catch(function () {});
13791      }
13792
13793      function onPathChange() {
13794        var val = pathInput ? pathInput.value : "";
13795        // Discard stale upload sizes when the user edits the path manually.
13796        window._lastUploadSizes = null;
13797        updateReportTitleFromPath();
13798        autoSetOutputDir(val);
13799        updateSidebarSummary();
13800        clearTimeout(historyTimer);
13801        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
13802        if (previewTimer) clearTimeout(previewTimer);
13803        previewTimer = setTimeout(loadPreview, 280);
13804        suggestCoverageFile(val);
13805      }
13806
13807      if (pathInput) {
13808        pathInput.addEventListener("input", onPathChange);
13809      }
13810
13811      if (outputDirInput) {
13812        outputDirInput.addEventListener("input", function () {
13813          outputDirInput.dataset.userEdited = "1";
13814          syncStripOutputRoot();
13815          updateReview();
13816        });
13817      }
13818
13819      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
13820        if (!node) return;
13821        node.addEventListener("input", function () {
13822          updateReview();
13823          if (previewTimer) clearTimeout(previewTimer);
13824          previewTimer = setTimeout(loadPreview, 280);
13825        });
13826      });
13827
13828      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
13829        var node = document.getElementById(id);
13830        if (node) node.addEventListener("change", updateReview);
13831      });
13832
13833      if (reportTitleInput) {
13834        reportTitleInput.addEventListener("input", function () {
13835          reportTitleTouched = reportTitleInput.value.trim().length > 0;
13836          updateReportTitleFromPath();
13837          updateReview();
13838        });
13839      }
13840
13841      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
13842      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
13843      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
13844      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
13845
13846      artifactCards.forEach(function (card) {
13847        card.addEventListener("click", function () {
13848          if (card.classList.contains("artifact-locked")) return;
13849          toggleArtifactCard(card);
13850          updateReview();
13851        });
13852      });
13853
13854      if (coverageInput) {
13855        coverageInput.addEventListener("input", function () {
13856          if (coverageInput.value.trim()) setCovStatus("idle");
13857        });
13858      }
13859
13860      if (form && loading && submitButton) {
13861        form.addEventListener("submit", function (e) {
13862          e.preventDefault();
13863          submitButton.disabled = true;
13864          submitButton.textContent = "Scanning...";
13865          startAsyncAnalysis(new FormData(form));
13866        });
13867      }
13868
13869      function openPath(folder) {
13870        if (!folder) return;
13871        fetch('/open-path?path=' + encodeURIComponent(folder))
13872          .then(function (r) { return r.json(); })
13873          .then(function (d) {
13874            if (d && d.server_mode_disabled)
13875              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
13876          })
13877          .catch(function () {});
13878      }
13879
13880      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
13881        btn.addEventListener('click', function () {
13882          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
13883        });
13884      });
13885
13886      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
13887      if (wsOutputLink) {
13888        wsOutputLink.addEventListener('click', function () {
13889          openPath(wsOutputLink.dataset.folder || '');
13890        });
13891      }
13892
13893      loadSavedTheme();
13894      updateMixedPolicyUI();
13895      updatePythonDocstringUI();
13896      applyScanPreset();
13897      updatePresetDescriptions();
13898      applyArtifactPreset();
13899      updateReview();
13900      updateScrollProgress(); // initialise bar to 0% (step 1)
13901      window.addEventListener("scroll", updateScrollProgress, { passive: true });
13902      onPathChange();         // seed output dir, history badge, and preview from initial path
13903      loadPreview();
13904      updateStepNav(1);
13905
13906      // Restore step from URL hash on initial load (e.g., back-forward cache)
13907      (function() {
13908        var hashMatch = location.hash.match(/^#step([1-4])$/);
13909        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
13910      })();
13911
13912      (function randomizeWatermarks() {
13913        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
13914        if (!wms.length) return;
13915        var placed = [];
13916        function tooClose(top, left) {
13917          for (var i = 0; i < placed.length; i++) {
13918            var dt = Math.abs(placed[i][0] - top);
13919            var dl = Math.abs(placed[i][1] - left);
13920            if (dt < 16 && dl < 12) return true;
13921          }
13922          return false;
13923        }
13924        function pick(leftBand) {
13925          for (var attempt = 0; attempt < 50; attempt++) {
13926            var top = Math.random() * 88 + 2;
13927            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13928            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
13929          }
13930          var top = Math.random() * 88 + 2;
13931          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13932          placed.push([top, left]);
13933          return [top, left];
13934        }
13935        var half = Math.floor(wms.length / 2);
13936        wms.forEach(function (img, i) {
13937          var pos = pick(i < half);
13938          var size = Math.floor(Math.random() * 80 + 110);
13939          var rot = (Math.random() * 360).toFixed(1);
13940          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
13941          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;
13942        });
13943      })();
13944
13945      (function spawnCodeParticles() {
13946        var container = document.getElementById('code-particles');
13947        if (!container) return;
13948        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'];
13949        for (var i = 0; i < 38; i++) {
13950          (function(idx) {
13951            var el = document.createElement('span');
13952            el.className = 'code-particle';
13953            el.textContent = snippets[idx % snippets.length];
13954            var left = Math.random() * 94 + 2;
13955            var top = Math.random() * 88 + 6;
13956            var dur = (Math.random() * 10 + 9).toFixed(1);
13957            var delay = (Math.random() * 18).toFixed(1);
13958            var rot = (Math.random() * 26 - 13).toFixed(1);
13959            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13960            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';
13961            container.appendChild(el);
13962          })(i);
13963        }
13964      })();
13965    })();
13966  </script>
13967  <script nonce="{{ csp_nonce }}">
13968    (function () {
13969      var raw = {{ prefill_json|safe }};
13970      if (!raw || typeof raw !== 'object' || !raw.path) return;
13971      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output-dir') scrollInputToEnd(el); } }
13972      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
13973      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
13974      setVal('path-input', raw.path || '');
13975      setVal('include-globs', raw.include_globs || '');
13976      setVal('exclude-globs', raw.exclude_globs || '');
13977      setVal('output-dir', raw.output_dir || '');
13978      setVal('report-title', raw.report_title || '');
13979      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
13980      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
13981      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
13982      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
13983      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
13984      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
13985      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
13986      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
13987      setChecked('generate-html', raw.generate_html !== false);
13988      setChecked('generate-pdf', !!raw.generate_pdf);
13989      // Trigger dynamic UI updates after pre-fill.
13990      setTimeout(function () {
13991        var pathEl = document.getElementById('path-input');
13992        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
13993        var policyEl = document.getElementById('mixed-line-policy');
13994        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
13995      }, 80);
13996    })();
13997  </script>
13998  <script nonce="{{ csp_nonce }}">
13999  (function(){
14000    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'}];
14001    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);});}
14002    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14003    function init(){
14004      var btn=document.getElementById('settings-btn');if(!btn)return;
14005      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14006      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>';
14007      document.body.appendChild(m);
14008      var g=document.getElementById('scheme-grid');
14009      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);});
14010      var cl=document.getElementById('settings-close');
14011      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);
14012      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');});
14013      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14014      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14015    }
14016    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14017  }());
14018  </script>
14019  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
14020    <div class="wb-ftip-arrow"></div>
14021    <span id="wb-ftip-text"></span>
14022  </div>
14023  <script nonce="{{ csp_nonce }}">(function(){
14024    var tip=document.getElementById('wb-ftip');
14025    var txt=document.getElementById('wb-ftip-text');
14026    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
14027    if(!tip||!txt)return;
14028    function pos(el){
14029      var r=el.getBoundingClientRect();
14030      tip.style.display='block';
14031      var tw=tip.offsetWidth;
14032      var lx=r.left+r.width/2-tw/2;
14033      if(lx<8)lx=8;
14034      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
14035      tip.style.left=lx+'px';
14036      tip.style.top=(r.bottom+8)+'px';
14037      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';}
14038    }
14039    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
14040      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
14041      el.addEventListener('mouseleave',function(){tip.style.display='none';});
14042    });
14043  })();
14044  (function(){
14045    function fixArtifactHintSpacing(){
14046      var grid=document.querySelector('.artifact-grid');
14047      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
14048    }
14049    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
14050  }());
14051  (function(){
14052    var dot=document.getElementById('status-dot');
14053    var pingEl=document.getElementById('server-ping-ms');
14054    var tipEl=document.getElementById('server-tip-ping');
14055    var fm=document.getElementById('footer-mode');
14056    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)';}}
14057    function doPing(){
14058      var t0=performance.now();
14059      fetch('/healthz',{cache:'no-store'})
14060        .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);})
14061        .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)';}});
14062    }
14063    doPing();
14064    setInterval(doPing,5000);
14065    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');}
14066  })();
14067  </script>
14068  <footer class="site-footer">
14069    local code analysis - metrics, history and reports
14070    &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>
14071    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14072    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14073    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14074    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14075  </footer>
14076</body>
14077</html>
14078"##,
14079    ext = "html"
14080)]
14081struct IndexTemplate {
14082    version: &'static str,
14083    prefill_json: String,
14084    csp_nonce: String,
14085    git_repo: String,
14086    git_ref: String,
14087    git_label_json: String,
14088    git_output_dir_json: String,
14089    server_mode: bool,
14090}
14091
14092// ── SplashTemplate ────────────────────────────────────────────────────────────
14093
14094#[derive(Template)]
14095#[template(
14096    source = r##"
14097<!doctype html>
14098<html lang="en">
14099<head>
14100  <meta charset="utf-8">
14101  <meta name="viewport" content="width=device-width, initial-scale=1">
14102  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
14103  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14104  <style nonce="{{ csp_nonce }}">
14105    :root {
14106      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14107      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14108      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14109      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14110      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14111    }
14112    body.dark-theme {
14113      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14114      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14115    }
14116    *{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;}
14117    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14118    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14119    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14120    .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;}
14121    @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));}}
14122    .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);}
14123    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14124    .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));}
14125    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14126    .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;}
14127    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14128    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14129    @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; } }
14130    .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;}
14131    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14132    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14133    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14134    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14135    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14136    .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;}
14137    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14138    .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);}
14139    .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;}
14140    .settings-close:hover{color:var(--text);background:var(--surface-2);}
14141    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14142    .settings-modal-body{padding:14px 16px 16px;}
14143    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14144    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14145    .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;}
14146    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14147    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14148    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14149    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14150    .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;}
14151    .tz-select:focus{border-color:var(--oxide);}
14152    .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;}
14153    .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;}
14154    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
14155    .hero{text-align:center;margin:0 auto 18px;}
14156    .hero-logo-wrap{display:inline-block;cursor:default;}
14157    .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;}
14158    .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;}
14159    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
14160    .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;}
14161    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%);}
14162    .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;
14163      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
14164      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
14165      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;}
14166    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
14167    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
14168    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;}
14169    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
14170    .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;}
14171    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
14172    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
14173    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
14174    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
14175    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
14176    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
14177    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
14178    .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;}
14179    .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;}
14180    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14181    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14182    .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);}
14183    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
14184    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
14185    .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);}
14186    .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);}
14187    .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);}
14188    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
14189    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
14190    .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;}
14191    body.dark-theme .action-card-cta{color:var(--oxide);}
14192    .action-card.view .action-card-cta{color:var(--accent-2);}
14193    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
14194    .action-card.compare .action-card-cta{color:#7c3aed;}
14195    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
14196    .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);}
14197    .action-card.git-tools .action-card-cta{color:#15803d;}
14198    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
14199    .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);}
14200    .action-card.trend .action-card-cta{color:#0e7490;}
14201    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
14202    .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);}
14203    .action-card.automation .action-card-cta{color:#b45309;}
14204    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
14205    .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);}
14206    .action-card.test-metrics .action-card-cta{color:#be185d;}
14207    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
14208    .action-card:hover .action-card-cta{gap:12px;}
14209    .action-card.card-split{flex-direction:row;align-items:stretch;}
14210    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
14211    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
14212    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
14213    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
14214    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
14215    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
14216    .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;}
14217    .ac-badge.active{opacity:1;}
14218    .ac-badge.github{border-color:#555;color:#555;}
14219    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
14220    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
14221    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
14222    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
14223    body.dark-theme .ac-right-row{color:var(--muted);}
14224    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
14225    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
14226    .divider{height:1px;background:var(--line);margin:32px 0;}
14227    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
14228    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
14229    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
14230    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
14231      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
14232    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14233    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
14234    body.dark-theme .info-chip-val{color:var(--oxide);}
14235    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
14236    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
14237      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
14238      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
14239    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
14240      border:6px solid transparent;border-top-color:var(--text);}
14241    .info-chip:hover .info-chip-tip{display:block;}
14242    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
14243    .chip-slide.fading{filter:blur(5px);opacity:0;}
14244    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14245    .site-footer a{color:var(--muted);}
14246    .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;}
14247    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
14248    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
14249    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
14250    .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;}
14251    .lan-badge.local{background:var(--oxide-2);}
14252    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
14253    .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);}
14254    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
14255    .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;}
14256    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
14257    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
14258    .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;}
14259    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
14260    .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;}
14261    .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);}
14262    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
14263    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
14264    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
14265    .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;}
14266    @media (max-height: 1100px) {
14267      .page{padding-top:10px;}
14268      .hero{margin-bottom:10px;}
14269      .hero-logo{width:54px;height:60px;}
14270      .hero-logo-shadow{width:42px;}
14271      .hero-title{font-size:28px;}
14272      .hero-subtitle{font-size:13px;}
14273      .card-sections{gap:16px;margin-bottom:10px;}
14274      .card-section-grid-2,.card-section-grid-3{gap:10px;}
14275      .action-card{padding:8px 15px 8px;}
14276      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
14277      .action-card-icon svg{width:18px;height:18px;}
14278      .action-card-title{font-size:13px;}
14279      .action-card-desc{font-size:11px;margin-bottom:6px;}
14280      .action-card-cta{font-size:11px;}
14281      .ac-right-row{font-size:11px;}
14282      .divider{margin:14px 0;}
14283      .info-strip{gap:7px;margin-bottom:12px;}
14284      .info-chip{padding:7px 10px;}
14285      .info-chip-val{font-size:13px;}
14286      .info-chip-label{font-size:9px;}
14287      .site-footer{padding:8px 24px;font-size:12px;}
14288    }
14289    @media (max-height: 850px) {
14290      .page{padding-top:6px;}
14291      .hero{margin-bottom:6px;}
14292      .hero-logo{width:42px;height:46px;}
14293      .hero-title{font-size:22px;}
14294      .hero-subtitle{font-size:12px;}
14295      .card-sections{gap:10px;}
14296      .action-card-desc{margin-bottom:4px;}
14297      .divider{margin:8px 0;}
14298      .info-strip{margin-bottom:6px;}
14299      .lan-local-hint{margin-top:10px;}
14300    }
14301  </style>
14302</head>
14303<body>
14304  <div class="background-watermarks" aria-hidden="true">
14305    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14306    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14307    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14308    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14309    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14310    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14311    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14312  </div>
14313  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14314  <div class="top-nav">
14315    <div class="top-nav-inner">
14316      <a class="brand" href="/">
14317        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14318        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14319      </a>
14320      <div class="nav-right">
14321        <a class="nav-pill" href="/">Home</a>
14322        <div class="nav-dropdown">
14323          <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>
14324          <div class="nav-dropdown-menu">
14325            <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>
14326          </div>
14327        </div>
14328        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14329        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14330        <div class="nav-dropdown">
14331          <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>
14332          <div class="nav-dropdown-menu">
14333            <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>
14334          </div>
14335        </div>
14336        <div class="server-status-wrap" id="server-status-wrap">
14337          <div class="nav-pill server-online-pill" id="server-status-pill">
14338            <span class="status-dot" id="status-dot"></span>
14339            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
14340            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14341          </div>
14342          <div class="server-status-tip">
14343            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
14344            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14345          </div>
14346        </div>
14347        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14348          <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>
14349        </button>
14350        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14351          <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>
14352          <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>
14353        </button>
14354      </div>
14355    </div>
14356  </div>
14357
14358  <div class="page">
14359    <div class="hero">
14360      <div class="hero-logo-wrap" id="hero-logo-wrap">
14361        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
14362      </div>
14363      <div class="hero-logo-shadow"></div>
14364      <div class="hero-title-wrap">
14365        <div class="hero-title-aura" aria-hidden="true"></div>
14366        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
14367      </div>
14368      <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>
14369    </div>
14370
14371    <div class="card-sections">
14372
14373      <div>
14374        <div class="card-section-label">Analysis</div>
14375        <div class="card-section-grid-2">
14376          <a class="action-card scan card-split" href="/scan-setup">
14377            <div class="action-card-left">
14378              <div class="action-card-icon">
14379                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
14380              </div>
14381              <div class="action-card-title">Scan Project</div>
14382              <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>
14383              <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>
14384            </div>
14385            <div class="action-card-sep"></div>
14386            <div class="action-card-right">
14387              <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>
14388              <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>
14389              <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>
14390              <div class="ac-right-stat" id="acp-scan-stat"></div>
14391            </div>
14392          </a>
14393          <a class="action-card test-metrics card-split" href="/test-metrics">
14394            <div class="action-card-left">
14395              <div class="action-card-icon">
14396                <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>
14397              </div>
14398              <div class="action-card-title">Test Metrics</div>
14399              <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>
14400              <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>
14401            </div>
14402            <div class="action-card-sep"></div>
14403            <div class="action-card-right">
14404              <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>
14405              <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>
14406              <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>
14407              <div class="ac-right-stat" id="acp-test-stat"></div>
14408            </div>
14409          </a>
14410        </div>
14411      </div>
14412
14413      <div>
14414        <div class="card-section-label">Reports &amp; Insights</div>
14415        <div class="card-section-grid-3">
14416          <a class="action-card view" href="/view-reports">
14417            <div class="action-card-icon">
14418              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
14419            </div>
14420            <div class="action-card-title">View Reports</div>
14421            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
14422            <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>
14423          </a>
14424          <a class="action-card compare" href="/compare-scans">
14425            <div class="action-card-icon">
14426              <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>
14427            </div>
14428            <div class="action-card-title">Compare Scans</div>
14429            <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>
14430            <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>
14431          </a>
14432          <a class="action-card trend" href="/trend-reports">
14433            <div class="action-card-icon">
14434              <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>
14435            </div>
14436            <div class="action-card-title">Trend Report</div>
14437            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
14438            <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>
14439          </a>
14440        </div>
14441      </div>
14442
14443      <div>
14444        <div class="card-section-label">Developer Tools</div>
14445        <div class="card-section-grid-2">
14446          <a class="action-card git-tools card-split" href="/git-browser">
14447            <div class="action-card-left">
14448              <div class="action-card-icon">
14449                <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>
14450              </div>
14451              <div class="action-card-title">Git Browser</div>
14452              <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>
14453              <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>
14454            </div>
14455            <div class="action-card-sep"></div>
14456            <div class="action-card-right">
14457              <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>
14458              <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>
14459              <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>
14460            </div>
14461          </a>
14462          <a class="action-card automation card-split" href="/integrations">
14463            <div class="action-card-left">
14464              <div class="action-card-icon">
14465                <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>
14466              </div>
14467              <div class="action-card-title">Integrations</div>
14468              <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>
14469              <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>
14470            </div>
14471            <div class="action-card-sep"></div>
14472            <div class="action-card-right">
14473              <div class="ac-badges-grid">
14474                <span class="ac-badge github"     id="acp-gh">GitHub</span>
14475                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
14476                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
14477                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
14478              </div>
14479              <div class="ac-right-stat" id="acp-int-stat"></div>
14480            </div>
14481          </a>
14482        </div>
14483      </div>
14484
14485    </div>
14486
14487    {% if server_mode %}
14488    <div class="lan-card server">
14489      <div class="lan-card-header">
14490        <span class="lan-badge">LAN server</span>
14491        Accessible on your network
14492      </div>
14493      {% if let Some(ip) = lan_ip %}
14494      <div class="lan-url-row">
14495        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
14496        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
14497          <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>
14498          Copy URL
14499        </button>
14500      </div>
14501      <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>
14502      {% if has_api_key %}
14503      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
14504      {% endif %}
14505      {% else %}
14506      <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>
14507      {% endif %}
14508    </div>
14509    {% endif %}
14510
14511    <div class="divider"></div>
14512
14513    <div class="info-strip">
14514      <div class="info-chip">
14515        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
14516        <div class="chip-slide">
14517          <div class="info-chip-val">41</div>
14518          <div class="info-chip-label">Languages</div>
14519        </div>
14520      </div>
14521      <div class="info-chip">
14522        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
14523        <div class="chip-slide">
14524          <div class="info-chip-val">100%</div>
14525          <div class="info-chip-label">Self-contained</div>
14526        </div>
14527      </div>
14528      <div class="info-chip">
14529        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
14530        <div class="chip-slide">
14531          <div class="info-chip-val">HTML+PDF</div>
14532          <div class="info-chip-label">Exportable reports</div>
14533        </div>
14534      </div>
14535      <div class="info-chip">
14536        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
14537        <div class="chip-slide">
14538          <div class="info-chip-val">Webhook</div>
14539          <div class="info-chip-label">3 platforms</div>
14540        </div>
14541      </div>
14542      <div class="info-chip">
14543        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
14544        <div class="chip-slide">
14545          <div class="info-chip-val">IEEE</div>
14546          <div class="info-chip-label">1045-1992</div>
14547        </div>
14548      </div>
14549    </div>
14550
14551    {% if lan_ip.is_none() %}
14552    <div class="lan-local-hint">
14553      <strong>Want teammates on the same network to access this?</strong><br>
14554      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
14555    </div>
14556    {% endif %}
14557  </div>
14558
14559  <footer class="site-footer">
14560    local code analysis - metrics, history and reports
14561    &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>
14562    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14563    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14564    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14565    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14566  </footer>
14567
14568  <script nonce="{{ csp_nonce }}">
14569    (function () {
14570      var storageKey = 'oxide-sloc-theme';
14571      var body = document.body;
14572      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
14573      var toggle = document.getElementById('theme-toggle');
14574      if (toggle) toggle.addEventListener('click', function () {
14575        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
14576        body.classList.toggle('dark-theme', next === 'dark');
14577        try { localStorage.setItem(storageKey, next); } catch(e) {}
14578      });
14579      var copyBtn = document.getElementById('lan-copy-btn');
14580      if (copyBtn) copyBtn.addEventListener('click', function() {
14581        var btn = this;
14582        var el = document.getElementById('lan-url-val');
14583        if (!el) return;
14584        var url = el.textContent.trim();
14585        if (navigator.clipboard) {
14586          navigator.clipboard.writeText(url).then(function() {
14587            var orig = btn.innerHTML;
14588            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!';
14589            setTimeout(function() { btn.innerHTML = orig; }, 1800);
14590          });
14591        }
14592      });
14593      (function randomizeWatermarks() {
14594        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14595        if (!wms.length) return;
14596        var placed = [];
14597        function tooClose(top, left) {
14598          for (var i = 0; i < placed.length; i++) {
14599            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
14600            if (dt < 16 && dl < 12) return true;
14601          }
14602          return false;
14603        }
14604        function pick(leftBand) {
14605          for (var attempt = 0; attempt < 50; attempt++) {
14606            var top = Math.random() * 88 + 2;
14607            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14608            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14609          }
14610          var top = Math.random() * 88 + 2;
14611          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14612          placed.push([top, left]); return [top, left];
14613        }
14614        var half = Math.floor(wms.length / 2);
14615        wms.forEach(function (img, i) {
14616          var pos = pick(i < half);
14617          var size = Math.floor(Math.random() * 100 + 120);
14618          var rot = (Math.random() * 360).toFixed(1);
14619          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
14620          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;
14621        });
14622      })();
14623
14624      (function spawnCodeParticles() {
14625        var container = document.getElementById('code-particles');
14626        if (!container) return;
14627        var snippets = [
14628          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
14629          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
14630          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
14631          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
14632          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
14633        ];
14634        var count = 38;
14635        for (var i = 0; i < count; i++) {
14636          (function(idx) {
14637            var el = document.createElement('span');
14638            el.className = 'code-particle';
14639            var text = snippets[idx % snippets.length];
14640            el.textContent = text;
14641            var left = Math.random() * 94 + 2;
14642            var top = Math.random() * 88 + 6;
14643            var dur = (Math.random() * 10 + 9).toFixed(1);
14644            var delay = (Math.random() * 18).toFixed(1);
14645            var rot = (Math.random() * 26 - 13).toFixed(1);
14646            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14647            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
14648              + '--rot:' + rot + 'deg;--op:' + op + ';'
14649              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
14650            container.appendChild(el);
14651          })(i);
14652        }
14653      })();
14654      (function heroAnimations() {
14655        var sub = document.getElementById('hero-subtitle');
14656        if (sub) {
14657          var full = sub.textContent.trim();
14658          sub.textContent = '';
14659          sub.style.opacity = '1';
14660          var cursor = document.createElement('span');
14661          cursor.className = 'hero-cursor';
14662          sub.appendChild(cursor);
14663          var i = 0;
14664          setTimeout(function() {
14665            var iv = setInterval(function() {
14666              if (i < full.length) {
14667                sub.insertBefore(document.createTextNode(full[i]), cursor);
14668                i++;
14669              } else {
14670                clearInterval(iv);
14671                setTimeout(function() {
14672                  cursor.style.transition = 'opacity 1s ease';
14673                  cursor.style.opacity = '0';
14674                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
14675                }, 2400);
14676              }
14677            }, 11);
14678          }, 374);
14679        }
14680      })();
14681      (function logoBob() {
14682        var logo = document.querySelector('.hero-logo');
14683        var shadow = document.querySelector('.hero-logo-shadow');
14684        if (!logo) return;
14685        var cycleStart = null, cycleDur = 3600;
14686        var peakY = -14, peakScale = 1.07, peakRot = 0;
14687        function newCycle() {
14688          cycleDur = 3000 + Math.random() * 1840;
14689          peakY = -(9 + Math.random() * 13.8);
14690          peakScale = 1.04 + Math.random() * 0.081;
14691          peakRot = (Math.random() * 11.5 - 5.75);
14692        }
14693        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
14694        newCycle();
14695        function frame(ts) {
14696          if (cycleStart === null) cycleStart = ts;
14697          var t = (ts - cycleStart) / cycleDur;
14698          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
14699          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
14700          var y = peakY * phase;
14701          var sc = 1 + (peakScale - 1) * phase;
14702          var rot = peakRot * Math.sin(Math.PI * phase);
14703          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
14704          if (shadow) {
14705            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
14706            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
14707          }
14708          requestAnimationFrame(frame);
14709        }
14710        requestAnimationFrame(frame);
14711      })();
14712      (function mouseEffects() {
14713        var heroTitle = document.getElementById('hero-title');
14714        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
14715        function tick() {
14716          raf = null;
14717          if (heroTitle) {
14718            var r = heroTitle.getBoundingClientRect();
14719            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
14720            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
14721            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
14722          }
14723        }
14724        document.addEventListener('mousemove', function(e) {
14725          mx = e.clientX; my = e.clientY;
14726          if (!raf) raf = requestAnimationFrame(tick);
14727        });
14728        document.addEventListener('mouseleave', function() {
14729          if (heroTitle) {
14730            heroTitle.style.transition = 'transform 0.5s ease';
14731            heroTitle.style.transform = '';
14732            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
14733          }
14734        });
14735        document.querySelectorAll('.action-card').forEach(function(card) {
14736          card.addEventListener('mousemove', function(e) {
14737            var rect = card.getBoundingClientRect();
14738            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
14739            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
14740            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
14741            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
14742          });
14743          card.addEventListener('mouseleave', function() {
14744            card.style.transition = '';
14745            card.style.transform = '';
14746          });
14747        });
14748      })();
14749      (function chipSlideshow() {
14750        var slides = [
14751          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
14752          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
14753          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
14754          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
14755          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
14756        ];
14757        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
14758        var indices = [0,0,0,0,0];
14759        var paused = [false,false,false,false,false];
14760        chips.forEach(function(chip, i) {
14761          chip.addEventListener('mouseenter', function() { paused[i] = true; });
14762          chip.addEventListener('mouseleave', function() { paused[i] = false; });
14763        });
14764        function advance(i) {
14765          if (paused[i]) return;
14766          var chip = chips[i];
14767          var inner = chip.querySelector('.chip-slide');
14768          if (!inner) return;
14769          inner.classList.add('fading');
14770          setTimeout(function() {
14771            indices[i] = (indices[i] + 1) % slides[i].length;
14772            var s = slides[i][indices[i]];
14773            chip.querySelector('.info-chip-val').textContent = s.v;
14774            chip.querySelector('.info-chip-label').textContent = s.l;
14775            inner.classList.remove('fading');
14776          }, 720);
14777        }
14778        setInterval(function() {
14779          chips.forEach(function(chip, i) { advance(i); });
14780        }, 6000);
14781      })();
14782      (function cardLiveData() {
14783        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
14784          var el = document.getElementById('acp-scan-stat');
14785          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
14786        }).catch(function(){});
14787        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
14788          var el = document.getElementById('acp-test-stat');
14789          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
14790        }).catch(function(){});
14791        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
14792          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
14793          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
14794          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
14795          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
14796          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
14797          var stat = document.getElementById('acp-int-stat');
14798          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
14799        }).catch(function(){});
14800        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
14801          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
14802        }).catch(function(){});
14803      })();
14804    })();
14805  </script>
14806  <script nonce="{{ csp_nonce }}">
14807  (function(){
14808    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'}];
14809    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);});}
14810    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14811    function init(){
14812      var btn=document.getElementById('settings-btn');if(!btn)return;
14813      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14814      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>';
14815      document.body.appendChild(m);
14816      var g=document.getElementById('scheme-grid');
14817      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);});
14818      var cl=document.getElementById('settings-close');
14819      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);
14820      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');});
14821      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14822      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14823    }
14824    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14825  }());
14826  </script>
14827  <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>
14828</body>
14829</html>
14830"##,
14831    ext = "html"
14832)]
14833struct SplashTemplate {
14834    csp_nonce: String,
14835    server_mode: bool,
14836    lan_ip: Option<String>,
14837    port: u16,
14838    version: &'static str,
14839    has_api_key: bool,
14840}
14841
14842// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
14843
14844#[derive(Template)]
14845#[template(
14846    source = r##"
14847<!doctype html>
14848<html lang="en">
14849<head>
14850  <meta charset="utf-8">
14851  <meta name="viewport" content="width=device-width, initial-scale=1">
14852  <title>OxideSLOC — Start a Scan</title>
14853  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14854  <style nonce="{{ csp_nonce }}">
14855    :root {
14856      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
14857      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14858      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14859      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14860      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14861    }
14862    body.dark-theme {
14863      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14864      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14865    }
14866    *{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;}
14867    .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);}
14868    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14869    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
14870    .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));}
14871    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
14872    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
14873    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
14874    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14875    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14876    @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; } }
14877    .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;}
14878    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14879    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14880    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14881    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14882    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14883    .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;}
14884    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14885    .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);}
14886    .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;}
14887    .settings-close:hover{color:var(--text);background:var(--surface-2);}
14888    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14889    .settings-modal-body{padding:14px 16px 16px;}
14890    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14891    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14892    .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;}
14893    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14894    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14895    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14896    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14897    .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;}
14898    .tz-select:focus{border-color:var(--oxide);}
14899    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
14900    .page-header{text-align:center;margin-bottom:16px;}
14901    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
14902    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
14903    /* Cards */
14904    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
14905    .option-card-wrap{position:relative;}
14906    .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;}
14907    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
14908    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14909    .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;}
14910    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
14911    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
14912    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
14913    .card-top-row{display:flex;align-items:center;gap:20px;}
14914    /* Two-column layout inside each card */
14915    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
14916    .card-left{display:flex;align-items:flex-start;min-width:0;}
14917    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
14918    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
14919    .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);}
14920    .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);}
14921    .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);}
14922    .card-text{min-width:0;}
14923    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
14924    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
14925    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
14926    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
14927    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
14928    /* Right CTA column */
14929    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
14930    .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;}
14931    /* Re-scan count badge */
14932    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
14933    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
14934    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
14935    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
14936    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
14937    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
14938    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
14939    body.dark-theme .btn-secondary{color:var(--oxide);}
14940    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
14941    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
14942    /* File input overlay — must be full-width so it aligns with other card-right buttons */
14943    .file-input-wrap{position:relative;width:100%;}
14944    .file-input-wrap .btn{width:100%;}
14945    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
14946    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14947    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14948    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14949    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
14950    @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
14951    /* Recent list (card 3 — full-width section below header) */
14952    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
14953    .recent-list{display:flex;flex-direction:column;gap:8px;}
14954    .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;}
14955    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
14956    .recent-item-info{flex:1;min-width:0;}
14957    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
14958    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
14959    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
14960    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
14961    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14962    .site-footer a{color:var(--muted);}
14963    @media(max-width:680px){
14964      .card-body{grid-template-columns:1fr;}
14965      .card-right{flex-direction:row;flex-wrap:wrap;}
14966      .btn{flex:1;}
14967    }
14968    .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;}
14969    .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;}
14970    .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;}
14971  </style>
14972</head>
14973<body>
14974  <div class="background-watermarks" aria-hidden="true">
14975    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14976    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14977    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14978    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14979    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14980    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14981    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14982  </div>
14983  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14984  <div class="top-nav">
14985    <div class="top-nav-inner">
14986      <a class="brand" href="/">
14987        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14988        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14989      </a>
14990      <div class="nav-right">
14991        <a class="nav-pill" href="/">Home</a>
14992        <div class="nav-dropdown">
14993          <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>
14994          <div class="nav-dropdown-menu">
14995            <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>
14996          </div>
14997        </div>
14998        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14999        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15000        <div class="nav-dropdown">
15001          <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>
15002          <div class="nav-dropdown-menu">
15003            <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>
15004          </div>
15005        </div>
15006        <div class="server-status-wrap" id="server-status-wrap">
15007          <div class="nav-pill server-online-pill" id="server-status-pill">
15008            <span class="status-dot" id="status-dot"></span>
15009            <span id="server-status-label">Server</span>
15010            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15011          </div>
15012          <div class="server-status-tip">
15013            OxideSLOC is running — accessible on your network.
15014            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15015          </div>
15016        </div>
15017        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15018          <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>
15019        </button>
15020        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15021          <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>
15022          <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>
15023        </button>
15024      </div>
15025    </div>
15026  </div>
15027
15028  <div class="page">
15029    <div class="page-header">
15030      <h1>How would you like to scan?</h1>
15031      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
15032    </div>
15033
15034    <div class="option-grid">
15035
15036      <!-- Option 1: New scan -->
15037      <div class="option-card-wrap">
15038        <div class="option-card">
15039        <div class="option-icon new-scan">
15040          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
15041        </div>
15042        <div class="card-body">
15043          <div class="card-left">
15044            <div class="card-text">
15045              <div class="option-title">Start a new scan</div>
15046              <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>
15047              <ul class="feature-list">
15048                <li>Live project scope preview before you run</li>
15049                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
15050                <li>HTML, PDF, and JSON output — your choice</li>
15051              </ul>
15052            </div>
15053          </div>
15054          <div class="card-right">
15055            <a class="btn btn-primary" href="/scan">
15056              Configure &amp; scan
15057              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
15058            </a>
15059            <p class="card-tip">Full 4-step setup · all options</p>
15060          </div>
15061        </div>
15062        </div>
15063      </div>
15064
15065      <!-- Option 2: Load from config file -->
15066      <div class="option-card-wrap">
15067        <div class="option-card">
15068        <div class="option-icon load-config">
15069          <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>
15070        </div>
15071        <div class="card-body">
15072          <div class="card-left">
15073            <div class="card-text">
15074              <div class="option-title">Load a saved config</div>
15075              <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>
15076              <ul class="feature-list">
15077                <li>All 15 settings restored from the file</li>
15078                <li>Fully editable — change path or output dir</li>
15079                <li>Works with any scan-config.json</li>
15080              </ul>
15081            </div>
15082          </div>
15083          <div class="card-right">
15084            <div class="file-input-wrap">
15085              <button class="btn btn-secondary" id="load-config-btn" type="button">
15086                <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>
15087                Choose config file
15088              </button>
15089              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
15090            </div>
15091            <p class="card-tip" id="config-file-name">Exported after every scan</p>
15092          </div>
15093        </div>
15094        </div>
15095      </div>
15096
15097      <!-- Option 3: Re-scan recent project -->
15098      <div class="option-card-wrap">
15099        <div class="option-card" id="recent-card">
15100        <div class="card-top-row">
15101          <div class="option-icon rescan">
15102            <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>
15103          </div>
15104          <div class="card-body">
15105            <div class="card-left">
15106              <div class="card-text">
15107                <div class="option-title">Re-scan a recent project</div>
15108                <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>
15109                <ul class="feature-list">
15110                  <li>All 15+ settings restored from the saved config</li>
15111                  <li>Path and output dir are editable before running</li>
15112                  <li>Only scans with a saved config appear here</li>
15113                </ul>
15114              </div>
15115            </div>
15116            <div class="card-right">
15117              <div class="rescan-count-box">
15118                <div class="rescan-count-num" id="rescan-count-num">—</div>
15119                <div class="rescan-count-label">saved configs</div>
15120              </div>
15121              <a class="btn btn-secondary" href="/view-reports">
15122                <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>
15123                View all runs
15124              </a>
15125              <p class="card-tip">Opens run history</p>
15126            </div>
15127          </div>
15128        </div>
15129        <div class="section-divider"></div>
15130        <div class="recent-list" id="recent-list">
15131          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
15132        </div>
15133        </div>
15134      </div>
15135
15136    </div>
15137  </div>
15138
15139  <footer class="site-footer">
15140    local code analysis - metrics, history and reports
15141    &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>
15142    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15143    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15144    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15145    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
15146  </footer>
15147
15148  <script nonce="{{ csp_nonce }}">
15149    (function () {
15150      var storageKey = 'oxide-sloc-theme';
15151      var body = document.body;
15152      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15153      var toggle = document.getElementById('theme-toggle');
15154      if (toggle) toggle.addEventListener('click', function () {
15155        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15156        body.classList.toggle('dark-theme', next === 'dark');
15157        try { localStorage.setItem(storageKey, next); } catch(e) {}
15158      });
15159
15160      (function randomizeWatermarks() {
15161        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15162        if (!wms.length) return;
15163        var placed = [];
15164        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; }
15165        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]; }
15166        var half = Math.floor(wms.length / 2);
15167        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; });
15168      })();
15169      (function spawnCodeParticles() {
15170        var container = document.getElementById('code-particles');
15171        if (!container) return;
15172        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'];
15173        var count = 38;
15174        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); }
15175      })();
15176      // Recent scans data injected from server
15177      var recentScans = {{ recent_scans_json|safe }};
15178
15179      function configToParams(cfg) {
15180        var p = new URLSearchParams();
15181        p.set('prefilled', '1');
15182        if (cfg.path) p.set('path', cfg.path);
15183        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
15184        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
15185        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
15186        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
15187        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
15188        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
15189        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
15190        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
15191        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
15192        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
15193        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
15194        if (cfg.report_title) p.set('report_title', cfg.report_title);
15195        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
15196        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
15197        return p;
15198      }
15199
15200      // Build recent scan list (capped at 3 visible entries)
15201      var list = document.getElementById('recent-list');
15202      var noNote = document.getElementById('no-recent-note');
15203      var hasAny = false;
15204      var MAX_RECENT = 3;
15205      if (Array.isArray(recentScans)) {
15206        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
15207        var shown = 0;
15208        validEntries.forEach(function (entry) {
15209          if (shown >= MAX_RECENT) return;
15210          shown++;
15211          hasAny = true;
15212          var item = document.createElement('div');
15213          item.className = 'recent-item';
15214          item.title = 'Restore all settings and open wizard';
15215          item.innerHTML =
15216            '<div class="recent-item-info">' +
15217              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
15218              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
15219            '</div>' +
15220            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
15221          item.addEventListener('click', function () {
15222            var params = configToParams(entry.config);
15223            window.location.href = '/scan?' + params.toString();
15224          });
15225          list.appendChild(item);
15226        });
15227        if (validEntries.length > MAX_RECENT) {
15228          var moreEl = document.createElement('div');
15229          moreEl.className = 'recent-more-link';
15230          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
15231          list.appendChild(moreEl);
15232        }
15233      }
15234      if (hasAny && noNote) noNote.style.display = 'none';
15235      // Update count badge
15236      var countEl = document.getElementById('rescan-count-num');
15237      if (countEl) {
15238        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
15239        countEl.textContent = total > 0 ? total : '0';
15240      }
15241
15242      // Config file loader
15243      var fileInput = document.getElementById('config-file-input');
15244      var fileName = document.getElementById('config-file-name');
15245      if (fileInput) {
15246        fileInput.addEventListener('change', function () {
15247          var file = fileInput.files && fileInput.files[0];
15248          if (!file) return;
15249          if (fileName) fileName.textContent = '✓ ' + file.name;
15250          var reader = new FileReader();
15251          reader.onload = function (e) {
15252            try {
15253              var cfg = JSON.parse(e.target.result);
15254              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
15255              var params = configToParams(cfg);
15256              window.location.href = '/scan?' + params.toString();
15257            } catch (err) {
15258              alert('Could not parse config file: ' + err.message);
15259            }
15260          };
15261          reader.readAsText(file);
15262        });
15263      }
15264
15265      function escHtml(s) {
15266        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
15267      }
15268    })();
15269  </script>
15270  <script nonce="{{ csp_nonce }}">
15271  (function(){
15272    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'}];
15273    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);});}
15274    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15275    function init(){
15276      var btn=document.getElementById('settings-btn');if(!btn)return;
15277      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15278      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>';
15279      document.body.appendChild(m);
15280      var g=document.getElementById('scheme-grid');
15281      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);});
15282      var cl=document.getElementById('settings-close');
15283      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);
15284      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');});
15285      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15286      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15287    }
15288    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15289  }());
15290  </script>
15291  <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>
15292</body>
15293</html>
15294"##,
15295    ext = "html"
15296)]
15297struct ScanSetupTemplate {
15298    version: &'static str,
15299    recent_scans_json: String,
15300    csp_nonce: String,
15301}
15302
15303#[derive(Template)]
15304#[template(
15305    source = r##"
15306<!doctype html>
15307<html lang="en">
15308<head>
15309  <meta charset="utf-8">
15310  <meta name="viewport" content="width=device-width, initial-scale=1">
15311  <title>OxideSLOC | {{ report_title }} | Report</title>
15312  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15313  <style nonce="{{ csp_nonce }}">
15314    :root {
15315      --radius: 18px;
15316      --bg: #f5efe8;
15317      --surface: rgba(255,255,255,0.82);
15318      --surface-2: #fbf7f2;
15319      --surface-3: #efe6dc;
15320      --line: #e6d0bf;
15321      --line-strong: #dcb89f;
15322      --text: #43342d;
15323      --muted: #7b675b;
15324      --muted-2: #a08777;
15325      --nav: #b85d33;
15326      --nav-2: #7a371b;
15327      --accent: #6f9bff;
15328      --accent-2: #4a78ee;
15329      --oxide: #d37a4c;
15330      --oxide-2: #b35428;
15331      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
15332      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
15333      --success-bg: #e8f5ed;
15334      --success-text: #1a8f47;
15335      --info-bg: #eef3ff;
15336      --info-text: #4467d8;
15337    }
15338
15339    body.dark-theme {
15340      --bg: #1b1511;
15341      --surface: #261c17;
15342      --surface-2: #2d221d;
15343      --surface-3: #372922;
15344      --line: #524238;
15345      --line-strong: #6c5649;
15346      --text: #f5ece6;
15347      --muted: #c7b7aa;
15348      --muted-2: #aa9485;
15349      --nav: #b85d33;
15350      --nav-2: #7a371b;
15351      --accent: #6f9bff;
15352      --accent-2: #4a78ee;
15353      --oxide: #d37a4c;
15354      --oxide-2: #b35428;
15355      --shadow: 0 18px 42px rgba(0,0,0,0.28);
15356      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
15357      --success-bg: #163927;
15358      --success-text: #8fe2a8;
15359      --info-bg: #1c2847;
15360      --info-text: #a9c1ff;
15361    }
15362
15363    * { box-sizing: border-box; }
15364    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); }
15365    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15366    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15367    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15368    .top-nav, .page { position: relative; z-index: 2; }
15369    .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); }
15370    .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; }
15371    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15372    .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)); }
15373    .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; }
15374    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15375    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15376    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15377    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15378    .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; }
15379    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15380    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15381    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15382    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15383    @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; } }
15384    .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; }
15385    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15386    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15387    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15388    .theme-toggle .icon-sun { display:none; }
15389    body.dark-theme .theme-toggle .icon-sun { display:block; }
15390    body.dark-theme .theme-toggle .icon-moon { display:none; }
15391    .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;}
15392    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15393    .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);}
15394    .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;}
15395    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15396    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15397    .settings-modal-body{padding:14px 16px 16px;}
15398    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15399    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15400    .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;}
15401    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15402    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15403    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15404    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15405    .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;}
15406    .tz-select:focus{border-color:var(--oxide);}
15407    .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; }
15408    .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;}
15409    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; }
15410    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
15411    .hero, .panel { padding: 22px; }
15412    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
15413    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15414    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
15415    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
15416    .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; }
15417    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
15418    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
15419    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
15420    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
15421    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
15422    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
15423    .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; }
15424    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
15425    .delta-card-val { font-size:16px; font-weight:800; }
15426    .delta-card-val.pos { color:#1e7e34; }
15427    .delta-card-val.neg { color:var(--neg); }
15428    .delta-card-val.mod { color:#b35428; }
15429    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
15430    .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; }
15431    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15432    .delta-card-inline:hover .delta-card-tip { opacity:1; }
15433    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
15434    .compare-ts { font-size:13px; color:var(--muted); }
15435    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
15436    .compare-arrow { color: var(--muted); }
15437    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
15438    .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; }
15439    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
15440    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
15441    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
15442    .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:flex-start; gap:6px; }
15443    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
15444    .run-mgmt-card .action-buttons { justify-content:flex-start; }
15445    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; }
15446    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
15447    .button, .copy-button {
15448      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;
15449    }
15450    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
15451    @keyframes spin { to { transform: rotate(360deg); } }
15452    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
15453    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
15454    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
15455    .path-item strong { display: block; margin-bottom: 6px; }
15456    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
15457    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
15458    .path-subitem { flex: 1; }
15459    .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); }
15460    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); }
15461    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
15462    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
15463    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
15464    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
15465    th { color: var(--muted); font-weight: 700; }
15466    tr:last-child td { border-bottom: none; }
15467    #subm-tbl col:nth-child(1){width:15%;}
15468    #subm-tbl col:nth-child(2){width:31%;}
15469    #subm-tbl col:nth-child(3){width:9%;}
15470    #subm-tbl col:nth-child(4){width:9%;}
15471    #subm-tbl col:nth-child(5){width:9%;}
15472    #subm-tbl col:nth-child(6){width:9%;}
15473    #subm-tbl col:nth-child(7){width:9%;}
15474    #subm-tbl col:nth-child(8){width:9%;}
15475    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
15476    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
15477    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
15478    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
15479    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
15480    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
15481    .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; }
15482    .soft-chip.success { gap:7px; padding:0 16px 0 12px; background:linear-gradient(135deg,rgba(26,143,71,0.12),rgba(26,143,71,0.06)); color:var(--success-text); border:1.5px solid rgba(26,143,71,0.35); box-shadow:0 0 0 4px rgba(26,143,71,0.07),0 2px 8px rgba(26,143,71,0.12); font-size:12px; letter-spacing:0.02em; }
15483    .soft-chip.success svg { flex:0 0 auto; }
15484    body.dark-theme .soft-chip.success { background:linear-gradient(135deg,rgba(143,226,168,0.12),rgba(143,226,168,0.05)); border-color:rgba(143,226,168,0.3); box-shadow:0 0 0 4px rgba(143,226,168,0.07),0 2px 8px rgba(0,0,0,0.2); }
15485    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
15486    .muted { color: var(--muted); }
15487    /* Run-ID chip row (mirrors HTML report) */
15488    .run-id-row { display:flex; flex-wrap:wrap; gap:10px; margin-top:14px; }
15489    .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; flex:1 1 180px; max-width:320px; }
15490    .run-id-chip[data-copy] { cursor:pointer; }
15491    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
15492    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
15493    .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; }
15494    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
15495    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15496    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
15497    .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; }
15498    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15499    .run-id-chip:hover .chip-tooltip { opacity:1; }
15500    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
15501    .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; }
15502    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
15503    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
15504    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
15505    /* Meta chips row */
15506    .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); }
15507    .meta-chip { display:inline-flex; align-items:center; gap:5px; padding:0 14px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
15508    .meta-chip:first-child { padding-left:0; }
15509    .meta-chip:last-child { border-right:none; }
15510    .meta-chip b { color:var(--text); font-weight:700; }
15511    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15512    .site-footer a{color:var(--muted);}
15513    .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; }
15514    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
15515    .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; }
15516    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
15517    /* Stat chips (matches HTML report) */
15518    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
15519    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
15520    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15521    .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; }
15522    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
15523    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
15524    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
15525    .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; }
15526    .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; }
15527    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15528    .stat-chip:hover .stat-chip-tip { opacity:1; }
15529    /* Submodule panel */
15530    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
15531    /* Metrics tables stack */
15532    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
15533    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
15534    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
15535    .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)); }
15536    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
15537    /* Metrics table */
15538    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
15539    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
15540    .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; }
15541    .metrics-table thead th:not(:first-child) { text-align: right; }
15542    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
15543    .metrics-table tbody tr:last-child td { border-bottom: none; }
15544    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
15545    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
15546    .metrics-table tbody tr:hover td { background: var(--surface-2); }
15547    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
15548    .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; }
15549    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
15550    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
15551    .mt-val-pos { color: var(--pos); font-weight: 700; }
15552    .mt-val-neg { color: var(--neg); font-weight: 700; }
15553    .mt-val-zero { color: var(--muted); }
15554    .mt-val-mod { color: var(--oxide-2); }
15555    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
15556    @media (max-width: 1180px) {
15557      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
15558      .nav-project-slot, .nav-status { justify-content:flex-start; }
15559      .hero-top { flex-direction: column; }
15560      .run-mgmt-strip { flex-direction: column; }
15561    }
15562    .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;}
15563    @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));}}
15564    .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;}
15565    /* ── Result-page chart controls ─────────────────────────────────────────── */
15566    .r-chart-section{margin-bottom:24px;}
15567    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
15568    .section-pair > .panel{flex-shrink:0;}
15569    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
15570    .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;}
15571    .r-chart-select:focus{border-color:var(--accent);}
15572    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
15573    .r-chart-container svg{display:block;width:100%;height:auto;}
15574    .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;}
15575    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
15576    .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;}
15577    .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);}
15578    .r-chart-modal-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:0 0 16px;display:block;}
15579    .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;}
15580    .r-chart-modal-close:hover{opacity:.7;}
15581    body.dark-theme .r-chart-modal{background:var(--surface);}
15582    .r-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
15583    .r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
15584    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
15585    .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;}
15586    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
15587    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
15588    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
15589    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
15590    #r-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:10px;padding:8px 13px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
15591    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
15592    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
15593    .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;}
15594    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
15595    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
15596    .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface-2);display:flex;flex-direction:column;}
15597    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
15598    .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%;}
15599    .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%;}
15600    body.has-report-banner .top-nav{top:27px;}
15601    body.has-report-banner{padding-bottom:27px;}
15602  </style>
15603</head>
15604<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
15605  <div class="background-watermarks" aria-hidden="true">
15606    <img src="/images/logo/logo-text.png" alt="" />
15607    <img src="/images/logo/logo-text.png" alt="" />
15608    <img src="/images/logo/logo-text.png" alt="" />
15609    <img src="/images/logo/logo-text.png" alt="" />
15610    <img src="/images/logo/logo-text.png" alt="" />
15611    <img src="/images/logo/logo-text.png" alt="" />
15612    <img src="/images/logo/logo-text.png" alt="" />
15613    <img src="/images/logo/logo-text.png" alt="" />
15614    <img src="/images/logo/logo-text.png" alt="" />
15615    <img src="/images/logo/logo-text.png" alt="" />
15616    <img src="/images/logo/logo-text.png" alt="" />
15617    <img src="/images/logo/logo-text.png" alt="" />
15618    <img src="/images/logo/logo-text.png" alt="" />
15619    <img src="/images/logo/logo-text.png" alt="" />
15620  </div>
15621  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15622  {% if let Some(banner) = report_header_footer %}
15623  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
15624  {% endif %}
15625  <div class="top-nav">
15626    <div class="top-nav-inner">
15627      <a class="brand" href="/">
15628        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15629        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15630      </a>
15631      <div class="nav-project-slot">
15632        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
15633      </div>
15634      <div class="nav-status">
15635        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
15636        <div class="nav-dropdown">
15637          <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>
15638          <div class="nav-dropdown-menu">
15639            <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>
15640          </div>
15641        </div>
15642        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
15643        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15644        <div class="nav-dropdown">
15645          <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>
15646          <div class="nav-dropdown-menu">
15647            <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>
15648          </div>
15649        </div>
15650        <div class="server-status-wrap" id="server-status-wrap">
15651          <div class="nav-pill server-online-pill" id="server-status-pill">
15652            <span class="status-dot" id="status-dot"></span>
15653            <span id="server-status-label">Server</span>
15654            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15655          </div>
15656          <div class="server-status-tip">
15657            OxideSLOC is running — accessible on your network.
15658            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15659          </div>
15660        </div>
15661        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15662          <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>
15663        </button>
15664        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
15665          <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>
15666          <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>
15667        </button>
15668      </div>
15669    </div>
15670  </div>
15671
15672  <div class="page">
15673    <section class="hero">
15674      <div class="hero-top">
15675        <div>
15676          <div class="soft-chip success"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
15677          <div style="display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;">
15678            <h1 class="hero-title">{{ report_title }}</h1>
15679            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
15680          </div>
15681        </div>
15682        <div class="hero-quick-actions">
15683          {% if server_mode %}
15684          <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>
15685          {% else %}
15686          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
15687          {% endif %}
15688          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
15689          {% if !server_mode %}
15690          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
15691          {% endif %}
15692        </div>
15693      </div>
15694
15695      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
15696      <div class="run-id-row">
15697        <span class="run-id-chip" data-copy="{{ run_id }}" style="max-width:none;flex:2 1 300px;">
15698          <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>
15699          <span class="run-id-chip-value">{{ run_id }}</span>
15700          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
15701        </span>
15702        {% match git_commit_long %}
15703          {% when Some with (long_sha) %}
15704          <span class="run-id-chip" data-copy="{{ long_sha }}">
15705            <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>
15706            <span class="run-id-chip-value">{{ long_sha }}</span>
15707            <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
15708          </span>
15709          {% when None %}
15710          <span class="run-id-chip muted-chip">
15711            <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>
15712            <span class="run-id-chip-value">Not detected</span>
15713            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
15714          </span>
15715        {% endmatch %}
15716        {% match git_branch %}
15717          {% when Some with (branch) %}
15718          <span class="run-id-chip">
15719            <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>
15720            <span class="run-id-chip-value">{{ branch }}</span>
15721            <span class="chip-tooltip">Git branch active at scan time</span>
15722          </span>
15723          {% when None %}
15724          <span class="run-id-chip muted-chip">
15725            <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>
15726            <span class="run-id-chip-value">Not detected</span>
15727            <span class="chip-tooltip">No Git branch was found for this scan</span>
15728          </span>
15729        {% endmatch %}
15730        {% match git_author %}
15731          {% when Some with (author) %}
15732          <span class="run-id-chip">
15733            <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>
15734            <span class="run-id-chip-value">{{ author }}</span>
15735            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
15736          </span>
15737          {% when None %}
15738          <span class="run-id-chip muted-chip">
15739            <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>
15740            <span class="run-id-chip-value">Not detected</span>
15741            <span class="chip-tooltip">No commit author was found for this scan</span>
15742          </span>
15743        {% endmatch %}
15744      </div>
15745
15746      <!-- Scan metadata row -->
15747      <div class="meta">
15748        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
15749        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
15750        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
15751        <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
15752        <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
15753      </div>
15754
15755      <!-- 12 summary stat chips -->
15756      <div class="summary-strip">
15757        <div class="stat-chip" data-raw="{{ physical_lines }}">
15758          <div class="stat-chip-label">Physical lines</div>
15759          <div class="stat-chip-val">{{ physical_lines }}</div>
15760          <div class="stat-chip-exact"></div>
15761          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
15762        </div>
15763        <div class="stat-chip" data-raw="{{ code_lines }}">
15764          <div class="stat-chip-label">Code</div>
15765          <div class="stat-chip-val">{{ code_lines }}</div>
15766          <div class="stat-chip-exact"></div>
15767          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
15768        </div>
15769        <div class="stat-chip" data-raw="{{ comment_lines }}">
15770          <div class="stat-chip-label">Comments</div>
15771          <div class="stat-chip-val">{{ comment_lines }}</div>
15772          <div class="stat-chip-exact"></div>
15773          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
15774        </div>
15775        <div class="stat-chip" data-raw="{{ blank_lines }}">
15776          <div class="stat-chip-label">Blank</div>
15777          <div class="stat-chip-val">{{ blank_lines }}</div>
15778          <div class="stat-chip-exact"></div>
15779          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
15780        </div>
15781        <div class="stat-chip" data-raw="{{ mixed_lines }}">
15782          <div class="stat-chip-label">Mixed separate</div>
15783          <div class="stat-chip-val">{{ mixed_lines }}</div>
15784          <div class="stat-chip-exact"></div>
15785          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
15786        </div>
15787        <div class="stat-chip" data-raw="{{ functions }}">
15788          <div class="stat-chip-label">Functions</div>
15789          <div class="stat-chip-val">{{ functions }}</div>
15790          <div class="stat-chip-exact"></div>
15791          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
15792        </div>
15793        <div class="stat-chip" data-raw="{{ classes }}">
15794          <div class="stat-chip-label">Classes / Types</div>
15795          <div class="stat-chip-val">{{ classes }}</div>
15796          <div class="stat-chip-exact"></div>
15797          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
15798        </div>
15799        <div class="stat-chip" data-raw="{{ variables }}">
15800          <div class="stat-chip-label">Variables</div>
15801          <div class="stat-chip-val">{{ variables }}</div>
15802          <div class="stat-chip-exact"></div>
15803          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
15804        </div>
15805        <div class="stat-chip" data-raw="{{ imports }}">
15806          <div class="stat-chip-label">Imports</div>
15807          <div class="stat-chip-val">{{ imports }}</div>
15808          <div class="stat-chip-exact"></div>
15809          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
15810        </div>
15811        <div class="stat-chip" data-raw="{{ test_count }}">
15812          <div class="stat-chip-label">Tests</div>
15813          <div class="stat-chip-val">{{ test_count }}</div>
15814          <div class="stat-chip-exact"></div>
15815          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
15816        </div>
15817        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
15818          <div class="stat-chip-label">Code density</div>
15819          <div class="stat-chip-val stat-chip-density-val">—</div>
15820          <div class="stat-chip-exact"></div>
15821          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
15822        </div>
15823        <div class="stat-chip" data-raw="{{ files_analyzed }}">
15824          <div class="stat-chip-label">Files analyzed</div>
15825          <div class="stat-chip-val">{{ files_analyzed }}</div>
15826          <div class="stat-chip-exact"></div>
15827          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
15828        </div>
15829      </div>
15830
15831      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
15832      <div class="compare-banner">
15833        <div class="compare-banner-body">
15834          <div class="compare-banner-meta">
15835            <span class="compare-label">Previous scan</span>
15836            <span class="compare-ts">{{ prev_ts }}</span>
15837            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
15838            {% if let Some(prev_code) = prev_run_code_lines %}
15839            <div class="compare-banner-stats" style="margin-top:4px;">
15840              <span>Code before: <strong>{{ prev_code }}</strong></span>
15841              <span class="compare-arrow">→</span>
15842              <span>Code now: <strong>{{ code_lines }}</strong></span>
15843              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
15844              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
15845            </div>
15846            {% endif %}
15847          </div>
15848          {% if delta_lines_added.is_some() %}
15849          <div class="delta-cards-inline">
15850            <div class="delta-card-inline">
15851              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
15852              <div class="delta-card-lbl">lines added</div>
15853              <div class="delta-card-tip">Code lines added since the previous scan</div>
15854            </div>
15855            <div class="delta-card-inline">
15856              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
15857              <div class="delta-card-lbl">lines removed</div>
15858              <div class="delta-card-tip">Code lines removed since the previous scan</div>
15859            </div>
15860            <div class="delta-card-inline">
15861              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
15862              <div class="delta-card-lbl">unmodified lines</div>
15863              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
15864            </div>
15865            <div class="delta-card-inline">
15866              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
15867              <div class="delta-card-lbl">files modified</div>
15868              <div class="delta-card-tip">Files with at least one line changed</div>
15869            </div>
15870            <div class="delta-card-inline">
15871              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
15872              <div class="delta-card-lbl">files added</div>
15873              <div class="delta-card-tip">New files added since the previous scan</div>
15874            </div>
15875            <div class="delta-card-inline">
15876              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
15877              <div class="delta-card-lbl">files removed</div>
15878              <div class="delta-card-tip">Files deleted since the previous scan</div>
15879            </div>
15880            <div class="delta-card-inline">
15881              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
15882              <div class="delta-card-lbl">files unchanged</div>
15883              <div class="delta-card-tip">Files with no changes since the previous scan</div>
15884            </div>
15885          </div>
15886          {% else %}
15887          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
15888            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
15889          </p>
15890          {% endif %}
15891          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
15892        </div>
15893      </div>
15894      {% endif %}{% endif %}
15895
15896      <div class="action-grid">
15897        <div class="action-card">
15898          <h3>HTML report</h3>
15899          <div class="action-buttons">
15900            {% match html_url %}
15901              {% when Some with (url) %}
15902                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
15903              {% when None %}{% endmatch %}
15904            {% match html_download_url %}
15905              {% when Some with (url) %}
15906                <a class="button secondary" href="{{ url }}">Download HTML</a>
15907              {% when None %}{% endmatch %}
15908            {% match html_path %}
15909              {% when Some with (_path) %}{% when None %}{% endmatch %}
15910            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
15911          </div>
15912        </div>
15913        <div class="action-card">
15914          <h3>PDF report</h3>
15915          <div class="action-buttons">
15916            {% match pdf_url %}
15917              {% when Some with (url) %}
15918                {% if pdf_generating %}
15919                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
15920                    <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>
15921                    Generating PDF…
15922                  </button>
15923                {% else %}
15924                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
15925                {% endif %}
15926              {% when None %}
15927                {% match html_url %}
15928                  {% when Some with (hurl) %}
15929                    <a class="button" href="{{ hurl }}?autoprint=1" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
15930                    <p class="action-empty-note" style="margin-top:6px;font-size:11px;">
15931                      No PDF renderer found on the server. Opens the HTML report in your browser
15932                      with the print dialog ready — choose <strong>Save as PDF</strong>.
15933                    </p>
15934                  {% when None %}
15935                    <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;">
15936                      PDF and HTML reports were not generated for this run. Re-run with HTML or PDF output enabled.
15937                    </p>
15938                {% endmatch %}
15939            {% endmatch %}
15940            {% match pdf_download_url %}
15941              {% when Some with (url) %}
15942                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
15943              {% when None %}{% endmatch %}
15944            {% match pdf_url %}
15945              {% when Some with (_) %}
15946                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
15947              {% when None %}{% endmatch %}
15948          </div>
15949        </div>
15950        <div class="action-card">
15951          <h3>JSON result</h3>
15952          <div class="action-buttons">
15953            {% match json_url %}
15954              {% when Some with (url) %}
15955                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
15956              {% when None %}{% endmatch %}
15957            {% match json_download_url %}
15958              {% when Some with (url) %}
15959                <a class="button secondary" href="{{ url }}">Download JSON</a>
15960              {% when None %}{% endmatch %}
15961            {% match json_path %}
15962              {% when Some with (_path) %}
15963                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
15964              {% when None %}
15965                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
15966              {% endmatch %}
15967          </div>
15968        </div>
15969        <div class="action-card">
15970          <h3>Scan config</h3>
15971          <div class="action-buttons">
15972            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
15973            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
15974            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
15975          </div>
15976        </div>
15977        {% if confluence_configured %}
15978        <div class="action-card" id="confluenceCard">
15979          <h3>Confluence</h3>
15980          <div class="action-buttons">
15981            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
15982            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
15983          </div>
15984          <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>
15985        </div>
15986        {% endif %}
15987      </div>
15988      <div class="run-mgmt-strip">
15989        <div class="run-mgmt-card">
15990          <h3>Download bundle</h3>
15991          <div class="action-buttons">
15992            <button class="button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
15993          </div>
15994          <p class="action-empty-note">Downloads a .tar.gz archive containing every artifact for this run (HTML, PDF, JSON, CSV, scan config).</p>
15995        </div>
15996        <div class="run-mgmt-card" id="delete-run-card">
15997          <h3>Delete run</h3>
15998          <div class="action-buttons">
15999            <button class="button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete this run</button>
16000          </div>
16001          <p class="action-empty-note">Permanently removes all artifacts for this run from disk. This action cannot be undone.</p>
16002        </div>
16003      </div>
16004      {% if confluence_configured %}
16005      <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;">
16006        <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);">
16007          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
16008          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
16009          <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;">
16010          <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>
16011          <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;">
16012          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16013          <div style="display:flex;gap:10px;justify-content:flex-end;">
16014            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
16015            <button class="button" id="confSubmitBtn" type="button">Post</button>
16016          </div>
16017        </div>
16018      </div>
16019      {% endif %}
16020      <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;">
16021        <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);">
16022          <div style="font-size:16px;font-weight:800;margin-bottom:10px;color:#b23030;">Delete run — irreversible</div>
16023          <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>
16024          <div id="delete-run-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16025          <div style="display:flex;gap:10px;justify-content:flex-end;">
16026            <button class="button secondary" id="delete-run-cancel" type="button">Cancel</button>
16027            <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;">Yes, delete permanently</button>
16028          </div>
16029        </div>
16030      </div>
16031      {% if !submodule_rows.is_empty() %}
16032      <div class="submodule-panel">
16033        <div class="toolbar-row">
16034          <div>
16035            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
16036            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
16037          </div>
16038          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
16039        </div>
16040        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
16041        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
16042          <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>
16043          <thead>
16044            <tr>
16045              <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>
16046              <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>
16047              <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>
16048              <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>
16049              <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>
16050              <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>
16051              <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>
16052              <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>
16053            </tr>
16054          </thead>
16055          <tbody>
16056            {% for row in submodule_rows %}
16057            <tr>
16058              <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>
16059              <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>
16060              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
16061              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
16062              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
16063              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
16064              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
16065              <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>
16066            </tr>
16067            {% endfor %}
16068          </tbody>
16069        </table>
16070        </div>
16071      </div>
16072      {% endif %}
16073
16074      <div class="metrics-tables-stack">
16075
16076        <div class="metrics-table-wrap">
16077          <div class="metrics-table-title">Files</div>
16078          <table class="metrics-table">
16079            <thead>
16080              <tr>
16081                <th>Metric</th>
16082                <th>This Run</th>
16083                <th>Previous</th>
16084                <th>Change</th>
16085              </tr>
16086            </thead>
16087            <tbody>
16088              <tr>
16089                <td>Files analyzed</td>
16090                <td class="mt-val-large">{{ files_analyzed }}</td>
16091                <td>{{ prev_fa_str }}</td>
16092                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
16093              </tr>
16094              <tr>
16095                <td>Files skipped</td>
16096                <td>{{ files_skipped }}</td>
16097                <td>{{ prev_fs_str }}</td>
16098                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
16099              </tr>
16100              <tr>
16101                <td>Files modified</td>
16102                <td class="mt-val-na">—</td>
16103                <td class="mt-val-na">—</td>
16104                <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>
16105              </tr>
16106              <tr>
16107                <td>Files unchanged</td>
16108                <td class="mt-val-na">—</td>
16109                <td class="mt-val-na">—</td>
16110                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
16111              </tr>
16112            </tbody>
16113          </table>
16114        </div>
16115
16116        <div class="metrics-table-wrap">
16117          <div class="metrics-table-title">Line Counts</div>
16118          <table class="metrics-table">
16119            <thead>
16120              <tr>
16121                <th>Metric</th>
16122                <th>This Run</th>
16123                <th>Previous</th>
16124                <th>Change</th>
16125              </tr>
16126            </thead>
16127            <tbody>
16128              <tr>
16129                <td>Physical lines</td>
16130                <td class="mt-val-large">{{ physical_lines }}</td>
16131                <td>{{ prev_pl_str }}</td>
16132                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
16133              </tr>
16134              <tr>
16135                <td>Code lines</td>
16136                <td class="mt-val-large">{{ code_lines }}</td>
16137                <td>{{ prev_cl_str }}</td>
16138                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
16139              </tr>
16140              <tr>
16141                <td>Comment lines</td>
16142                <td>{{ comment_lines }}</td>
16143                <td>{{ prev_cml_str }}</td>
16144                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
16145              </tr>
16146              <tr>
16147                <td>Blank lines</td>
16148                <td>{{ blank_lines }}</td>
16149                <td>{{ prev_bl_str }}</td>
16150                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
16151              </tr>
16152              <tr>
16153                <td>Mixed (separate)</td>
16154                <td>{{ mixed_lines }}</td>
16155                <td class="mt-val-na">—</td>
16156                <td class="mt-val-na">—</td>
16157              </tr>
16158            </tbody>
16159          </table>
16160        </div>
16161
16162        <div class="metrics-tables-lower">
16163          <div class="metrics-table-wrap">
16164            <div class="metrics-table-title">Code Structure</div>
16165            <table class="metrics-table">
16166              <thead>
16167                <tr>
16168                  <th>Metric</th>
16169                  <th>This Run</th>
16170                </tr>
16171              </thead>
16172              <tbody>
16173                <tr>
16174                  <td>Functions</td>
16175                  <td>{{ functions }}</td>
16176                </tr>
16177                <tr>
16178                  <td>Classes / Types</td>
16179                  <td>{{ classes }}</td>
16180                </tr>
16181                <tr>
16182                  <td>Variables</td>
16183                  <td>{{ variables }}</td>
16184                </tr>
16185                <tr>
16186                  <td>Imports</td>
16187                  <td>{{ imports }}</td>
16188                </tr>
16189              </tbody>
16190            </table>
16191          </div>
16192
16193          <div class="metrics-table-wrap">
16194            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
16195            <table class="metrics-table">
16196              <thead>
16197                <tr>
16198                  <th>Metric</th>
16199                  <th>Change</th>
16200                </tr>
16201              </thead>
16202              <tbody>
16203                <tr>
16204                  <td>Lines added</td>
16205                  <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>
16206                </tr>
16207                <tr>
16208                  <td>Lines removed</td>
16209                  <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>
16210                </tr>
16211                <tr>
16212                  <td>Lines modified (net)</td>
16213                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
16214                </tr>
16215                <tr>
16216                  <td>Lines unmodified</td>
16217                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
16218                </tr>
16219              </tbody>
16220            </table>
16221          </div>
16222        </div>
16223
16224      </div>
16225
16226      <div class="path-list">
16227        <div class="path-item">
16228          <div class="path-item-label">Project path</div>
16229          <code>{{ project_path }}</code>
16230        </div>
16231        <div class="path-item">
16232          <div class="path-item-label">Git branch</div>
16233          {% if let Some(branch) = git_branch %}
16234          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
16235          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
16236          {% else %}
16237          <code style="color:var(--muted)">—</code>
16238          {% endif %}
16239        </div>
16240        <div class="path-item">
16241          <div class="path-item-label">Output folder</div>
16242          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
16243        </div>
16244        <div class="path-item">
16245          <div class="path-item-label">Run ID</div>
16246          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
16247            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
16248            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
16249          </div>
16250        </div>
16251      </div>
16252    </section>
16253
16254    <div id="r-tt" aria-hidden="true"></div>
16255
16256    <div class="section-pair">
16257    <section class="panel">
16258        <div class="toolbar-row">
16259          <div>
16260            <h2>Language breakdown</h2>
16261            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
16262          </div>
16263        </div>
16264        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
16265    </section>
16266
16267    <section class="panel r-chart-section">
16268      <div class="toolbar-row" style="margin-bottom:16px;">
16269        <div>
16270          <h2>Visualizations</h2>
16271          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
16272        </div>
16273      </div>
16274
16275      <div class="r-viz-grid">
16276        <div class="r-viz-card">
16277          <p class="r-viz-card-title">Language Composition</p>
16278          <div class="r-chart-tab-bar">
16279            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
16280            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
16281          </div>
16282          <div class="r-chart-container" id="r-composition-chart"></div>
16283        </div>
16284        <div class="r-viz-card">
16285          <p class="r-viz-card-title">Files vs Code Lines</p>
16286          <div class="r-chart-container" id="r-scatter-chart"></div>
16287        </div>
16288        {% if has_semantic_data %}
16289        <div class="r-viz-card">
16290          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16291            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
16292            <select class="r-chart-select" id="r-semantic-metric">
16293              <option value="functions">Functions</option>
16294              <option value="classes">Classes</option>
16295              <option value="variables">Variables</option>
16296              <option value="imports">Imports</option>
16297            </select>
16298            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16299          </div>
16300          <div class="r-chart-container" id="r-semantic-chart"></div>
16301        </div>
16302        {% endif %}
16303        <div class="r-viz-card">
16304          <p class="r-viz-card-title">Comment Density</p>
16305          <div class="r-chart-container" id="r-density-chart"></div>
16306        </div>
16307        {% if has_submodule_data %}
16308        <div class="r-viz-card">
16309          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
16310            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Submodule Breakdown</p>
16311            <select class="r-chart-select" id="r-sub-metric">
16312              <option value="code">Code Lines</option>
16313              <option value="comment">Comments</option>
16314              <option value="blank">Blank Lines</option>
16315              <option value="physical">Physical Lines</option>
16316              <option value="files">Files</option>
16317            </select>
16318            <select class="r-chart-select" id="r-sub-sort">
16319              <option value="desc">Value ↓</option>
16320              <option value="asc">Value ↑</option>
16321              <option value="name">Name A→Z</option>
16322            </select>
16323          </div>
16324          <div class="r-chart-container" id="r-submodule-chart"></div>
16325        </div>
16326        {% endif %}
16327      </div>
16328
16329    </section>
16330    </div>
16331
16332  </div>
16333
16334  <script nonce="{{ csp_nonce }}">
16335    (function () {
16336      var body = document.body;
16337      var themeToggle = document.getElementById('theme-toggle');
16338      var storageKey = 'oxide-sloc-theme';
16339
16340      function applyTheme(theme) {
16341        body.classList.toggle('dark-theme', theme === 'dark');
16342      }
16343
16344      function loadSavedTheme() {
16345        try {
16346          var saved = localStorage.getItem(storageKey);
16347          if (saved === 'dark' || saved === 'light') {
16348            applyTheme(saved);
16349          }
16350        } catch (e) {}
16351      }
16352
16353      if (themeToggle) {
16354        themeToggle.addEventListener('click', function () {
16355          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
16356          applyTheme(nextTheme);
16357          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
16358        });
16359      }
16360
16361      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
16362        button.addEventListener('click', function () {
16363          var value = button.getAttribute('data-copy-value') || '';
16364          if (!value) return;
16365          var originalText = button.textContent;
16366          function flashSuccess() {
16367            button.textContent = 'Copied!';
16368            setTimeout(function () { button.textContent = originalText; }, 1800);
16369          }
16370          function flashFail() {
16371            button.textContent = 'Copy failed';
16372            setTimeout(function () { button.textContent = originalText; }, 2000);
16373          }
16374          if (navigator.clipboard && navigator.clipboard.writeText) {
16375            navigator.clipboard.writeText(value).then(flashSuccess, function () {
16376              fallbackCopy(value, flashSuccess, flashFail);
16377            });
16378          } else {
16379            fallbackCopy(value, flashSuccess, flashFail);
16380          }
16381        });
16382      });
16383      function fallbackCopy(text, onSuccess, onFail) {
16384        try {
16385          var ta = document.createElement('textarea');
16386          ta.value = text;
16387          ta.style.position = 'fixed';
16388          ta.style.top = '-9999px';
16389          ta.style.left = '-9999px';
16390          document.body.appendChild(ta);
16391          ta.focus();
16392          ta.select();
16393          var ok = document.execCommand('copy');
16394          document.body.removeChild(ta);
16395          if (ok) { onSuccess(); } else { onFail(); }
16396        } catch (e) { onFail(); }
16397      }
16398
16399      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16400        btn.addEventListener('click', function () {
16401          var folder = btn.getAttribute('data-folder') || '';
16402          if (!folder) return;
16403          fetch('/open-path?path=' + encodeURIComponent(folder))
16404            .then(function (r) { return r.json(); })
16405            .then(function (d) {
16406              if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16407            })
16408            .catch(function () {});
16409        });
16410      });
16411
16412      loadSavedTheme();
16413
16414      // ── Compact number formatting for stat chips ──────────────────────────
16415      (function(){
16416        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();}
16417        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
16418          var raw=parseInt(chip.getAttribute('data-raw'),10);
16419          if(isNaN(raw))return;
16420          var valEl=chip.querySelector('.stat-chip-val');
16421          if(valEl)valEl.textContent=fmt(raw);
16422          var exactEl=chip.querySelector('.stat-chip-exact');
16423          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
16424        });
16425        // Code density chip
16426        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
16427          var code=parseInt(chip.getAttribute('data-code'),10);
16428          var phys=parseInt(chip.getAttribute('data-physical'),10);
16429          if(isNaN(code)||isNaN(phys)||phys===0)return;
16430          var pct=(code/phys*100).toFixed(1)+'%';
16431          var valEl=chip.querySelector('.stat-chip-val');
16432          if(valEl)valEl.textContent=pct;
16433        });
16434        // Click-to-copy on run-id-chip elements
16435        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
16436          chip.addEventListener('click',function(){
16437            var val=chip.getAttribute('data-copy');
16438            if(!val)return;
16439            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
16440            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);}
16441            chip.classList.add('chip-copied-flash');
16442            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
16443          });
16444        });
16445      })();
16446
16447      // ── Shared tooltip for all result-page charts ─────────────────────────
16448      var rTT=(function(){
16449        var el=document.getElementById('r-tt');
16450        if(!el)return{s:function(){},h:function(){},m:function(){}};
16451        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
16452        function hide(){el.style.display='none';}
16453        function move(e){
16454          var x=e.clientX+16,y=e.clientY-12;
16455          var r=el.getBoundingClientRect();
16456          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
16457          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
16458          el.style.left=x+'px';el.style.top=y+'px';
16459        }
16460        return{s:show,h:hide,m:move};
16461      })();
16462      window.rTT=rTT;
16463
16464      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
16465      (function(){
16466        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16467        document.addEventListener('mouseover',function(e){
16468          var t=e.target;
16469          while(t&&t.getAttribute){
16470            var l=t.getAttribute('data-ttl');
16471            if(l!==null){
16472              var v=t.getAttribute('data-ttv')||'';
16473              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
16474              return;
16475            }
16476            t=t.parentNode;
16477          }
16478        });
16479        document.addEventListener('mouseout',function(e){
16480          var t=e.target;
16481          while(t&&t.getAttribute){
16482            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
16483            t=t.parentNode;
16484          }
16485        });
16486        document.addEventListener('mousemove',function(e){
16487          var el=document.getElementById('r-tt');
16488          if(el&&el.style.display!=='none')rTT.m(e);
16489        });
16490      })();
16491
16492      // ── Language overview charts ───────────────────────────────────────────
16493      (function(){
16494        var D={{ lang_chart_json|safe }};
16495        if(!D||!D.length)return;
16496        var el=document.getElementById('result-lang-charts');
16497        if(!el)return;
16498        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16499        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
16500        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16501        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();}
16502        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16503        function px(n){return Math.round(n);}
16504        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+'"';}
16505        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
16506
16507        // Donut chart — height matches the stacked-bar chart so both panels align
16508        var rHb_d=28;
16509        var DH=Math.max(220,D.length*rHb_d+32);
16510        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
16511        var legX=204,DW=360;
16512        var legCount=D.length;
16513        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
16514        var legYStart=Math.round((DH-legCount*legSpacing)/2);
16515        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">';
16516        if(D.length===1){
16517          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
16518          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+'"/>';
16519        } else {
16520          var ang=-Math.PI/2;
16521          D.forEach(function(d,i){
16522            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
16523            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
16524            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
16525            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
16526            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
16527            var pct=Math.round(d.code/tot*100);
16528            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"/>';
16529            ang+=sw;
16530          });
16531        }
16532        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
16533        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
16534        D.forEach(function(d,i){
16535          var ly=legYStart+i*legSpacing;
16536          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
16537          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
16538        });
16539        ds+='</svg>';
16540
16541        // Horizontal stacked-bar chart — fills container width
16542        var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
16543        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
16544        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">';
16545        D.forEach(function(d,i){
16546          var y=6+i*rHb,x=LW;
16547          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
16548          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>';
16549          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;
16550          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;
16551          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"/>';
16552          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>';
16553        });
16554        var ly=SH-14;
16555        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>';
16556        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>';
16557        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>';
16558        bs+='</svg>';
16559        el.innerHTML='<div class="r-lang-overview">'+
16560          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
16561          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
16562        '</div>';
16563      })();
16564
16565      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
16566      (function(){
16567        var LANG_D={{ lang_chart_json|safe }};
16568        var SCAT_D={{ scatter_chart_json|safe }};
16569        var SEM_D={{ semantic_chart_json|safe }};
16570        var SUB_D={{ submodule_chart_json|safe }};
16571        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
16572        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16573        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();}
16574        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16575        function px(n){return Math.round(n);}
16576        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+'"';}
16577
16578        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
16579        function renderComposition(mode){
16580          var el=document.getElementById('r-composition-chart');
16581          if(!el||!LANG_D||!LANG_D.length)return;
16582          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16583          var LW=110,SH=224;
16584          var svgW=Math.max(320,el.offsetWidth||480);
16585          var BW=Math.max(120,svgW-LW-80);
16586          var legendH=24,topPad=4;
16587          var n=LANG_D.length||1;
16588          var rowTotal=Math.floor((SH-legendH-topPad)/n);
16589          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16590          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">';
16591          if(mode==='pct'){
16592            LANG_D.forEach(function(d,i){
16593              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
16594              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
16595              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16596              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>';
16597              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;
16598              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;
16599              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+'"/>';
16600              var pct=Math.round((d.code||0)/tot2*100);
16601              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
16602            });
16603          } else {
16604            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
16605            LANG_D.forEach(function(d,i){
16606              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
16607              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16608              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>';
16609              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;
16610              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;
16611              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+'"/>';
16612              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>';
16613            });
16614          }
16615          var ly=SH-legendH+4;
16616          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>';
16617          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>';
16618          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>';
16619          s+='</svg>';
16620          el.innerHTML=s;
16621        }
16622        renderComposition('abs');
16623        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
16624          btn.addEventListener('click',function(){
16625            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
16626            btn.classList.add('active');
16627            renderComposition(btn.getAttribute('data-rcomp'));
16628          });
16629        });
16630
16631        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
16632        (function(){
16633          var el=document.getElementById('r-scatter-chart');
16634          if(!el||!SCAT_D||!SCAT_D.length)return;
16635          var H=224,PL=52,PB=36,PT=12,PR=14;
16636          var W=Math.max(320,el.offsetWidth||480);
16637          var cW=W-PL-PR,cH=H-PT-PB;
16638          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
16639          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
16640          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
16641          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">';
16642          [0,0.25,0.5,0.75,1].forEach(function(t){
16643            var y=PT+cH*(1-t);
16644            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
16645            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>';
16646          });
16647          [0,0.25,0.5,0.75,1].forEach(function(t){
16648            var x=PL+cW*t;
16649            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
16650            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>';
16651          });
16652          SCAT_D.forEach(function(d,i){
16653            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
16654            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
16655            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"/>';
16656            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>';
16657          });
16658          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>';
16659          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>';
16660          s+='</svg>';
16661          el.innerHTML=s;
16662        })();
16663
16664        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
16665        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
16666        // the old vertical column layout on wide containers.
16667        function renderSemanticInEl(el,key,sh){
16668          if(!el||!SEM_D||!SEM_D.length)return;
16669          var LW=112,SH=sh||224;
16670          var svgW=Math.max(320,el.offsetWidth||480);
16671          var BW=Math.max(120,svgW-LW-80);
16672          var topPad=4,botPad=14;
16673          var n2=SEM_D.length||1;
16674          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
16675          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
16676          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
16677          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">';
16678          SEM_D.forEach(function(d,i){
16679            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
16680            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>';
16681            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"/>';
16682            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>';
16683          });
16684          s+='</svg>';
16685          el.innerHTML=s;
16686        }
16687        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,224);}
16688        var semSel=document.getElementById('r-semantic-metric');
16689        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
16690        var semExpand=document.getElementById('r-semantic-expand');
16691        if(semExpand){
16692          semExpand.addEventListener('click',function(){
16693            var key=semSel?semSel.value:'functions';
16694            var n=SEM_D.length||1;
16695            var modalH=Math.max(320,n*28+60);
16696            var overlay=document.createElement('div');
16697            overlay.className='r-chart-modal-overlay';
16698            overlay.innerHTML='<div class="r-chart-modal"><button class="r-chart-modal-close" aria-label="Close">&times;</button><span class="r-chart-modal-title">Semantic Metrics — Full View</span><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;"></div></div>';
16699            document.body.appendChild(overlay);
16700            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
16701            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
16702            var modalEl=document.getElementById('r-sem-modal-chart');
16703            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
16704          });
16705        }
16706
16707        // ── Comment Density: comments / (code + comments) per language ───────────
16708        function renderDensity(){
16709          var el=document.getElementById('r-density-chart');
16710          if(!el||!LANG_D||!LANG_D.length)return;
16711          var LW=112,SH=224;
16712          var svgW=Math.max(320,el.offsetWidth||480);
16713          var BW=Math.max(120,svgW-LW-80);
16714          var topPad=4,botPad=26;
16715          var n=LANG_D.length||1;
16716          var rowTotal=Math.floor((SH-topPad-botPad)/n);
16717          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16718          var densities=LANG_D.map(function(d){
16719            var sig=(d.code||0)+(d.comments||0);
16720            return sig>0?(d.comments||0)/sig:0;
16721          });
16722          var maxDen=Math.max.apply(null,densities)||1;
16723          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">';
16724          LANG_D.forEach(function(d,i){
16725            var den=densities[i],bw=den/maxDen*BW;
16726            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
16727            var pct=Math.round(den*100);
16728            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>';
16729            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"/>';
16730            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
16731            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>';
16732          });
16733          s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.5">comment ratio (higher = more documented)</text>';
16734          s+='</svg>';
16735          el.innerHTML=s;
16736        }
16737        renderDensity();
16738
16739        // ── Submodule: horizontal bar chart ────────────────────────────────────
16740        function renderSubmodule(key,sort){
16741          var el=document.getElementById('r-submodule-chart');
16742          if(!el||!SUB_D||!SUB_D.length)return;
16743          var data=SUB_D.slice();
16744          if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
16745          else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
16746          else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
16747          var LW=128,SH=224;
16748          var svgW=Math.max(320,el.offsetWidth||480);
16749          var BW=Math.max(120,svgW-LW-80);
16750          var topPad3=4,botPad3=14;
16751          var n3=data.length||1;
16752          var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
16753          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
16754          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
16755          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">';
16756          data.forEach(function(d,i){
16757            var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-bH)/2);
16758            s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor">'+esc(d.name||d.path||'?')+'</text>';
16759            if(bw>0.5)s+='<rect'+tt(d.name||'?',fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
16760            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>';
16761          });
16762          s+='</svg>';
16763          el.innerHTML=s;
16764        }
16765        var subSel=document.getElementById('r-sub-metric');
16766        var sortSel=document.getElementById('r-sub-sort');
16767        if(subSel){
16768          renderSubmodule('code','desc');
16769          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
16770          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
16771        }
16772
16773        // Re-render all SVG charts when the window is resized so bars fill the card.
16774        var _rResizeTimer;
16775        window.addEventListener('resize',function(){
16776          clearTimeout(_rResizeTimer);
16777          _rResizeTimer=setTimeout(function(){
16778            var rcompBtn=document.querySelector('[data-rcomp].active');
16779            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
16780            (function(){
16781              var scEl=document.getElementById('r-scatter-chart');
16782              if(!scEl||!SCAT_D||!SCAT_D.length)return;
16783              var H=224,PL=52,PB=36,PT=12,PR=14;
16784              var W=Math.max(320,scEl.offsetWidth||480);
16785              var cW=W-PL-PR,cH=H-PT-PB;
16786              var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
16787              var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
16788              var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
16789              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">';
16790              [0,0.25,0.5,0.75,1].forEach(function(t){var y=PT+cH*(1-t);s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxC*t))+'</text>';});
16791              [0,0.25,0.5,0.75,1].forEach(function(t){var x=PL+cW*t;s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxF*t))+'</text>';});
16792              SCAT_D.forEach(function(d,i){var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';if(r>6)s+='<text x="'+px(cx2)+'" y="'+(px(cy2)-px(r)-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.9" style="pointer-events:none;">'+esc(d.lang)+'</text>';});
16793              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>';
16794              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>';
16795              s+='</svg>';scEl.innerHTML=s;
16796            })();
16797            if(semSel)renderSemantic(semSel.value||'functions');
16798            renderDensity();
16799            if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
16800          },120);
16801        });
16802      })();
16803
16804      (function randomizeWatermarks() {
16805        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
16806        if (!wms.length) return;
16807        var placed = [];
16808        function tooClose(top, left) {
16809          for (var i = 0; i < placed.length; i++) {
16810            var dt = Math.abs(placed[i][0] - top);
16811            var dl = Math.abs(placed[i][1] - left);
16812            if (dt < 20 && dl < 18) return true;
16813          }
16814          return false;
16815        }
16816        function pick(leftBand) {
16817          for (var attempt = 0; attempt < 50; attempt++) {
16818            var top = Math.random() * 85 + 5;
16819            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
16820            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16821          }
16822          var top = Math.random() * 85 + 5;
16823          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
16824          placed.push([top, left]);
16825          return [top, left];
16826        }
16827        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
16828        var half = Math.floor(wms.length / 2);
16829        wms.forEach(function (img, i) {
16830          var pos = pick(i < half);
16831          var size = Math.floor(Math.random() * 100 + 160);
16832          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
16833          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
16834          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;
16835        });
16836      })();
16837
16838      (function spawnCodeParticles() {
16839        var container = document.getElementById('code-particles');
16840        if (!container) return;
16841        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'];
16842        for (var i = 0; i < 38; i++) {
16843          (function(idx) {
16844            var el = document.createElement('span');
16845            el.className = 'code-particle';
16846            el.textContent = snippets[idx % snippets.length];
16847            var left = Math.random() * 94 + 2;
16848            var top = Math.random() * 88 + 6;
16849            var dur = (Math.random() * 10 + 9).toFixed(1);
16850            var delay = (Math.random() * 18).toFixed(1);
16851            var rot = (Math.random() * 26 - 13).toFixed(1);
16852            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16853            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';
16854            container.appendChild(el);
16855          })(i);
16856        }
16857      })();
16858
16859      {% if pdf_generating %}
16860      // Poll for PDF readiness and swap the disabled button to a live link once done.
16861      (function() {
16862        var openBtn = document.getElementById('pdf-open-btn');
16863        var dlBtn = document.getElementById('pdf-download-btn');
16864        function checkPdf() {
16865          fetch('/api/runs/{{ run_id }}/pdf-status')
16866            .then(function(r) { return r.json(); })
16867            .then(function(d) {
16868              if (d.ready) {
16869                if (openBtn) {
16870                  var a = document.createElement('a');
16871                  a.className = 'button';
16872                  a.id = 'pdf-open-btn';
16873                  a.href = '/runs/pdf/{{ run_id }}';
16874                  a.target = '_blank';
16875                  a.rel = 'noopener';
16876                  a.textContent = 'Open PDF';
16877                  openBtn.replaceWith(a);
16878                }
16879                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
16880              } else {
16881                setTimeout(checkPdf, 3000);
16882              }
16883            })
16884            .catch(function() { setTimeout(checkPdf, 5000); });
16885        }
16886        setTimeout(checkPdf, 3000);
16887      })();
16888      {% endif %}
16889
16890    })();
16891  </script>
16892  <script nonce="{{ csp_nonce }}">
16893  (function(){
16894    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'}];
16895    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);});}
16896    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16897    function init(){
16898      var btn=document.getElementById('settings-btn');if(!btn)return;
16899      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16900      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>';
16901      document.body.appendChild(m);
16902      var g=document.getElementById('scheme-grid');
16903      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);});
16904      var cl=document.getElementById('settings-close');
16905      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);
16906      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');});
16907      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16908      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16909    }
16910    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16911  }());
16912  </script>
16913  <footer class="site-footer">
16914    local code analysis - metrics, history and reports
16915    &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>
16916    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16917    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16918    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16919    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16920  </footer>
16921  {% if confluence_configured %}
16922  <script nonce="{{ csp_nonce }}">
16923  (function() {
16924    var postBtn = document.getElementById('postConfluenceBtn');
16925    var copyBtn = document.getElementById('copyWikiBtn');
16926    var modal   = document.getElementById('confluenceModal');
16927    if (!postBtn || !modal) return;
16928
16929    postBtn.addEventListener('click', function() {
16930      document.getElementById('confStatus').style.display = 'none';
16931      modal.style.display = 'flex';
16932    });
16933    document.getElementById('confCancelBtn').addEventListener('click', function() {
16934      modal.style.display = 'none';
16935    });
16936    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
16937
16938    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
16939      var btn = this;
16940      btn.disabled = true;
16941      var status = document.getElementById('confStatus');
16942      status.style.display = 'block';
16943      status.style.background = '#dbeafe';
16944      status.style.color = '#1e40af';
16945      status.textContent = 'Posting to Confluence…';
16946      var resp = await fetch('/api/confluence/post', {
16947        method: 'POST',
16948        headers: { 'Content-Type': 'application/json' },
16949        body: JSON.stringify({
16950          run_id: '{{ run_id }}',
16951          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
16952          report_url: document.getElementById('confReportUrl').value.trim() || null
16953        })
16954      });
16955      var data = await resp.json();
16956      if (data.ok) {
16957        status.style.background = '#dcfce7'; status.style.color = '#166534';
16958        status.textContent = 'Posted! Page ID: ' + data.page_id;
16959      } else {
16960        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
16961        status.textContent = 'Error: ' + (data.error || 'Unknown error');
16962      }
16963      btn.disabled = false;
16964    });
16965
16966    if (copyBtn) {
16967      copyBtn.addEventListener('click', async function() {
16968        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
16969        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
16970        var text = await resp.text();
16971        try {
16972          await navigator.clipboard.writeText(text);
16973          var orig = copyBtn.textContent;
16974          copyBtn.textContent = 'Copied!';
16975          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
16976        } catch(e) {
16977          alert('Clipboard write failed — check browser permissions.');
16978        }
16979      });
16980    }
16981  })();
16982  </script>
16983  {% endif %}
16984  <script nonce="{{ csp_nonce }}">
16985  (function() {
16986    var deleteBtn = document.getElementById('delete-run-btn');
16987    var modal     = document.getElementById('delete-run-modal');
16988    var cancelBtn = document.getElementById('delete-run-cancel');
16989    var confirmBtn= document.getElementById('delete-run-confirm');
16990    if (!deleteBtn || !modal) return;
16991    deleteBtn.addEventListener('click', function() {
16992      document.getElementById('delete-run-status').style.display = 'none';
16993      modal.style.display = 'flex';
16994    });
16995    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
16996    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
16997    confirmBtn.addEventListener('click', async function() {
16998      confirmBtn.disabled = true;
16999      cancelBtn.disabled = true;
17000      var status = document.getElementById('delete-run-status');
17001      status.style.display = 'block';
17002      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
17003      status.textContent = 'Deleting…';
17004      try {
17005        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
17006        if (resp.status === 204 || resp.ok) {
17007          status.style.background = '#dcfce7'; status.style.color = '#166534';
17008          status.textContent = 'Deleted. Redirecting…';
17009          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
17010        } else {
17011          var d = await resp.json().catch(function(){return {};});
17012          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17013          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
17014          confirmBtn.disabled = false;
17015          cancelBtn.disabled = false;
17016        }
17017      } catch (e) {
17018        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17019        status.textContent = 'Network error: ' + String(e);
17020        confirmBtn.disabled = false;
17021        cancelBtn.disabled = false;
17022      }
17023    });
17024  })();
17025  </script>
17026  <script nonce="{{ csp_nonce }}">(function(){
17027    var bundleBtn = document.getElementById('download-bundle-btn');
17028    if (bundleBtn) {
17029      bundleBtn.addEventListener('click', function() {
17030        bundleBtn.disabled = true;
17031        var orig = bundleBtn.textContent;
17032        bundleBtn.textContent = 'Preparing…';
17033        fetch('/api/runs/{{ run_id }}/bundle')
17034          .then(function(r) {
17035            if (!r.ok) throw new Error('HTTP ' + r.status);
17036            return r.blob();
17037          })
17038          .then(function(blob) {
17039            var url = URL.createObjectURL(blob);
17040            var a = document.createElement('a');
17041            a.href = url;
17042            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
17043            document.body.appendChild(a);
17044            a.click();
17045            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
17046            bundleBtn.disabled = false;
17047            bundleBtn.textContent = orig;
17048          })
17049          .catch(function(e) {
17050            bundleBtn.disabled = false;
17051            bundleBtn.textContent = orig;
17052            alert('Bundle download failed: ' + String(e));
17053          });
17054      });
17055    }
17056  })();</script>
17057  <script nonce="{{ csp_nonce }}">(function(){
17058    var dot=document.getElementById('status-dot');
17059    var pingEl=document.getElementById('server-ping-ms');
17060    var tipEl=document.getElementById('server-tip-ping');
17061    var fm=document.getElementById('footer-mode');
17062    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)';}}
17063    function doPing(){
17064      var t0=performance.now();
17065      fetch('/healthz',{cache:'no-store'})
17066        .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);})
17067        .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)';}});
17068    }
17069    doPing();
17070    setInterval(doPing,5000);
17071    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');}
17072  })();</script>
17073  {% if let Some(banner) = report_header_footer %}
17074  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
17075  {% endif %}
17076</body>
17077</html>
17078"##,
17079    ext = "html"
17080)]
17081// Template structs need many bool fields to pass Askama rendering flags.
17082#[allow(clippy::struct_excessive_bools)]
17083struct ResultTemplate {
17084    version: &'static str,
17085    report_title: String,
17086    project_path: String,
17087    output_dir: String,
17088    run_id: String,
17089    files_analyzed: u64,
17090    files_skipped: u64,
17091    physical_lines: u64,
17092    code_lines: u64,
17093    comment_lines: u64,
17094    blank_lines: u64,
17095    mixed_lines: u64,
17096    functions: u64,
17097    classes: u64,
17098    variables: u64,
17099    imports: u64,
17100    html_url: Option<String>,
17101    pdf_url: Option<String>,
17102    json_url: Option<String>,
17103    html_download_url: Option<String>,
17104    pdf_download_url: Option<String>,
17105    json_download_url: Option<String>,
17106    html_path: Option<String>,
17107    json_path: Option<String>,
17108    prev_run_id: Option<String>,
17109    prev_run_timestamp: Option<String>,
17110    prev_run_code_lines: Option<u64>,
17111    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
17112    prev_fa_str: String,
17113    prev_fs_str: String,
17114    prev_pl_str: String,
17115    prev_cl_str: String,
17116    prev_cml_str: String,
17117    prev_bl_str: String,
17118    // Signed change column for main metrics
17119    delta_fa_str: String,
17120    delta_fa_class: String,
17121    delta_fs_str: String,
17122    delta_fs_class: String,
17123    delta_pl_str: String,
17124    delta_pl_class: String,
17125    delta_cl_str: String,
17126    delta_cl_class: String,
17127    delta_cml_str: String,
17128    delta_cml_class: String,
17129    delta_bl_str: String,
17130    delta_bl_class: String,
17131    // delta vs previous scan
17132    delta_lines_added: Option<i64>,
17133    delta_lines_removed: Option<i64>,
17134    delta_lines_net_str: String,
17135    delta_lines_net_class: String,
17136    delta_files_added: Option<usize>,
17137    delta_files_removed: Option<usize>,
17138    delta_files_modified: Option<usize>,
17139    delta_files_unchanged: Option<usize>,
17140    delta_unmodified_lines: Option<u64>,
17141    // git context
17142    git_branch: Option<String>,
17143    git_commit: Option<String>,
17144    git_commit_long: Option<String>,
17145    git_author: Option<String>,
17146    // scan metadata for hero section
17147    scan_performed_by: String,
17148    scan_time_display: String,
17149    os_display: String,
17150    test_count: u64,
17151    // history
17152    prev_scan_count: usize,
17153    current_scan_number: usize,
17154    // submodule breakdown (empty when not requested)
17155    submodule_rows: Vec<SubmoduleRow>,
17156    scan_config_url: String,
17157    lang_chart_json: String,
17158    // Askama reads these via proc-macro expansion; clippy can't trace through it.
17159    #[allow(dead_code)]
17160    scatter_chart_json: String,
17161    #[allow(dead_code)]
17162    semantic_chart_json: String,
17163    #[allow(dead_code)]
17164    submodule_chart_json: String,
17165    #[allow(dead_code)]
17166    has_submodule_data: bool,
17167    #[allow(dead_code)]
17168    has_semantic_data: bool,
17169    pdf_generating: bool,
17170    csp_nonce: String,
17171    /// Whether Confluence integration is configured — shows Post button when true.
17172    confluence_configured: bool,
17173    server_mode: bool,
17174    /// Header/footer identification banner, mirrored from the HTML/PDF report.
17175    report_header_footer: Option<String>,
17176    run_id_short: String,
17177}
17178
17179#[derive(Template)]
17180#[template(
17181    source = r##"
17182<!doctype html>
17183<html lang="en">
17184<head>
17185  <meta charset="utf-8">
17186  <meta name="viewport" content="width=device-width, initial-scale=1">
17187  <title>OxideSLOC | Analyzing…</title>
17188  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17189  <style nonce="{{ csp_nonce }}">
17190    :root {
17191      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17192      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17193      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17194      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17195    }
17196    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17197    *{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;}
17198    .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);}
17199    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17200    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
17201    .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));}
17202    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17203    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17204    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17205    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17206    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17207    @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; } }
17208    .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;}
17209    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17210    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17211    .page-body{padding:32px 24px 36px;}
17212    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
17213    .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;}
17214    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
17215    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
17216    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
17217    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
17218    .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;}
17219    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
17220    .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;}
17221    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
17222    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
17223    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
17224    .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;}
17225    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
17226    .hidden{display:none!important;}
17227    .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;}
17228    .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;}
17229    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
17230    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
17231    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
17232    .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);}
17233    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
17234    .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;}
17235    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
17236    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17237    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17238    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
17239    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17240    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
17241    @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
17242    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17243    .site-footer a{color:var(--muted);}
17244    .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;}
17245    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
17246    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
17247    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
17248  </style>
17249</head>
17250<body>
17251  <div class="background-watermarks" aria-hidden="true">
17252    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17253    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17254    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17255    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17256    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17257    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17258  </div>
17259  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17260  <nav class="top-nav">
17261    <div class="top-nav-inner">
17262      <a href="/" class="brand">
17263        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
17264        <div class="brand-copy">
17265          <h1 class="brand-title">OxideSLOC</h1>
17266          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17267        </div>
17268      </a>
17269      <div class="nav-right">
17270        <a class="nav-pill" href="/">Home</a>
17271        <div class="nav-dropdown">
17272          <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>
17273          <div class="nav-dropdown-menu">
17274            <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>
17275          </div>
17276        </div>
17277        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17278        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17279        <div class="nav-dropdown">
17280          <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>
17281          <div class="nav-dropdown-menu">
17282            <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>
17283          </div>
17284        </div>
17285        <div class="server-status-wrap" id="server-status-wrap">
17286          <div class="nav-pill server-online-pill" id="server-status-pill">
17287            <span class="status-dot" id="status-dot"></span>
17288            <span id="server-status-label">Server</span>
17289            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17290          </div>
17291          <div class="server-status-tip">
17292            OxideSLOC is running — accessible on your network.
17293            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17294          </div>
17295        </div>
17296        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17297          <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>
17298        </button>
17299        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17300          <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>
17301          <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>
17302        </button>
17303      </div>
17304    </div>
17305  </nav>
17306  <div class="page-body">
17307    <div class="wait-panel">
17308      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
17309      <h2 class="wait-title">Analyzing your project…</h2>
17310      <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
17311      <div class="path-block">{{ project_path }}</div>
17312      <div class="metrics-row">
17313        <div class="metric-card">
17314          <div class="metric-label">Elapsed</div>
17315          <div class="metric-value" id="elapsed">0s</div>
17316        </div>
17317        <div class="metric-card">
17318          <div class="metric-label">Phase</div>
17319          <div class="metric-value" id="phase">Starting</div>
17320        </div>
17321      </div>
17322      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
17323      <div class="warn-slow hidden" id="warn-slow">
17324        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.
17325      </div>
17326      <div class="err-panel hidden" id="err-panel">
17327        <strong>Analysis failed</strong>
17328        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
17329      </div>
17330      <div class="actions hidden" id="actions">
17331        <a href="/scan" class="btn-primary">Try Again</a>
17332        <a href="/view-reports" class="btn-outline">View Reports</a>
17333      </div>
17334    </div>
17335  </div>
17336  <script nonce="{{ csp_nonce }}">
17337    (function() {
17338      var WAIT_ID = {{ wait_id_json|safe }};
17339      var startTime = Date.now();
17340      var pollInterval = 1500;
17341      var retries = 0;
17342      var maxRetries = 5;
17343      var warnShown = false;
17344
17345      function elapsed() {
17346        return Math.floor((Date.now() - startTime) / 1000);
17347      }
17348
17349      function updateElapsed() {
17350        var s = elapsed();
17351        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
17352      }
17353
17354      function setPhase(txt) {
17355        document.getElementById('phase').textContent = txt;
17356      }
17357
17358      var elapsedTimer = setInterval(updateElapsed, 1000);
17359
17360      function poll() {
17361        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
17362          .then(function(r) {
17363            if (!r.ok) throw new Error('HTTP ' + r.status);
17364            return r.json();
17365          })
17366          .then(function(data) {
17367            retries = 0;
17368            if (data.state === 'complete') {
17369              clearInterval(elapsedTimer);
17370              setPhase('Done');
17371              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
17372            } else if (data.state === 'failed') {
17373              clearInterval(elapsedTimer);
17374              setPhase('Failed');
17375              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
17376              document.getElementById('err-panel').classList.remove('hidden');
17377              document.getElementById('actions').classList.remove('hidden');
17378            } else {
17379              // still running
17380              var s = elapsed();
17381              if (s > 90 && !warnShown) {
17382                warnShown = true;
17383                document.getElementById('warn-slow').classList.remove('hidden');
17384              }
17385              setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
17386              setTimeout(poll, pollInterval);
17387            }
17388          })
17389          .catch(function(err) {
17390            retries++;
17391            if (retries >= maxRetries) {
17392              clearInterval(elapsedTimer);
17393              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
17394              document.getElementById('err-panel').classList.remove('hidden');
17395              document.getElementById('actions').classList.remove('hidden');
17396            } else {
17397              // exponential back-off capped at 8s
17398              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
17399            }
17400          });
17401      }
17402
17403      setTimeout(poll, pollInterval);
17404    })();
17405  </script>
17406  <footer class="site-footer">
17407    local code analysis - metrics, history and reports
17408    &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>
17409    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17410    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17411    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17412    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17413  </footer>
17414  <script nonce="{{ csp_nonce }}">
17415    (function(){
17416      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
17417      if(s==="dark")b.classList.add("dark-theme");
17418      var tt=document.getElementById("theme-toggle");
17419      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
17420    })();
17421    (function spawnCodeParticles(){
17422      var c=document.getElementById('code-particles');if(!c)return;
17423      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'];
17424      for(var i=0;i<32;i++){(function(idx){
17425        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
17426        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
17427        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
17428        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
17429        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
17430        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
17431        c.appendChild(el);
17432      })(i);}
17433    })();
17434    (function randomizeWatermarks(){
17435      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17436      var placed=[];
17437      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;}
17438      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];}
17439      var half=Math.floor(wms.length/2);
17440      wms.forEach(function(img,i){
17441        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
17442        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
17443        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
17444        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
17445        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
17446        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
17447      });
17448    })();
17449  </script>
17450  <script nonce="{{ csp_nonce }}">
17451  (function(){
17452    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'}];
17453    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);});}
17454    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17455    function init(){
17456      var btn=document.getElementById('settings-btn');if(!btn)return;
17457      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17458      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>';
17459      document.body.appendChild(m);
17460      var g=document.getElementById('scheme-grid');
17461      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);});
17462      var cl=document.getElementById('settings-close');
17463      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);
17464      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');});
17465      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17466      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17467    }
17468    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17469  }());
17470  </script>
17471  <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>
17472</body>
17473</html>
17474"##,
17475    ext = "html"
17476)]
17477struct ScanWaitTemplate {
17478    version: &'static str,
17479    wait_id_json: String,
17480    project_path: String,
17481    csp_nonce: String,
17482}
17483
17484#[derive(Template)]
17485#[template(
17486    source = r##"
17487<!doctype html>
17488<html lang="en">
17489<head>
17490  <meta charset="utf-8">
17491  <meta name="viewport" content="width=device-width, initial-scale=1">
17492  <title>OxideSLOC | Error</title>
17493  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17494  <style nonce="{{ csp_nonce }}">
17495    :root {
17496      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17497      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17498      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17499      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17500    }
17501    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17502    *{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;}
17503    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17504    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17505    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
17506    .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);}
17507    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17508    .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));}
17509    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17510    .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;}
17511    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17512    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17513    @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; } }
17514    .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;}
17515    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17516    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17517    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17518    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17519    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17520    .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;}
17521    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17522    .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);}
17523    .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;}
17524    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17525    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17526    .settings-modal-body{padding:14px 16px 16px;}
17527    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17528    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17529    .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;}
17530    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17531    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17532    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17533    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17534    .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;}
17535    .tz-select:focus{border-color:var(--oxide);}
17536    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
17537    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
17538    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
17539    .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;}
17540    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
17541    .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);}
17542    .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;}
17543    .btn-secondary:hover{background:var(--line);}
17544    .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;}
17545    .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;}
17546    .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;}
17547    @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));}}
17548    .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;}
17549  </style>
17550</head>
17551<body>
17552  <div class="background-watermarks" aria-hidden="true">
17553    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17554    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17555    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17556    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17557    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17558    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17559  </div>
17560  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17561  <div class="top-nav">
17562    <div class="top-nav-inner">
17563      <a class="brand" href="/">
17564        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17565        <div class="brand-copy">
17566          <div class="brand-title">OxideSLOC</div>
17567          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17568        </div>
17569      </a>
17570      <div class="nav-right">
17571        <a class="nav-pill" href="/">Home</a>
17572        <div class="nav-dropdown">
17573          <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>
17574          <div class="nav-dropdown-menu">
17575            <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>
17576          </div>
17577        </div>
17578        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17579        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17580        <div class="nav-dropdown">
17581          <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>
17582          <div class="nav-dropdown-menu">
17583            <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>
17584          </div>
17585        </div>
17586        <div class="server-status-wrap" id="server-status-wrap">
17587          <div class="nav-pill server-online-pill" id="server-status-pill">
17588            <span class="status-dot" id="status-dot"></span>
17589            <span id="server-status-label">Server</span>
17590            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17591          </div>
17592          <div class="server-status-tip">
17593            OxideSLOC is running — accessible on your network.
17594            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17595          </div>
17596        </div>
17597        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17598          <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>
17599        </button>
17600        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17601          <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>
17602          <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>
17603        </button>
17604      </div>
17605    </div>
17606  </div>
17607
17608  <div class="page">
17609    <div class="panel">
17610      <h1>Error</h1>
17611      <div class="error-box">{{ message }}</div>
17612      <div class="actions">
17613        <a class="btn-primary" href="/scan">Back to setup</a>
17614        {% if let Some(report_url) = last_report_url %}
17615        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
17616        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
17617        {% else %}
17618        <a class="btn-secondary" href="/view-reports">View Reports</a>
17619        {% endif %}
17620      </div>
17621    </div>
17622  </div>
17623  <script nonce="{{ csp_nonce }}">
17624    (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");});})();
17625    (function spawnCodeParticles() {
17626      var container = document.getElementById('code-particles');
17627      if (!container) return;
17628      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'];
17629      for (var i = 0; i < 38; i++) {
17630        (function(idx) {
17631          var el = document.createElement('span');
17632          el.className = 'code-particle';
17633          el.textContent = snippets[idx % snippets.length];
17634          var left = Math.random() * 94 + 2;
17635          var top = Math.random() * 88 + 6;
17636          var dur = (Math.random() * 10 + 9).toFixed(1);
17637          var delay = (Math.random() * 18).toFixed(1);
17638          var rot = (Math.random() * 26 - 13).toFixed(1);
17639          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17640          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';
17641          container.appendChild(el);
17642        })(i);
17643      }
17644    })();
17645    (function randomizeWatermarks() {
17646      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17647      var placed = [];
17648      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; }
17649      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]; }
17650      var half = Math.floor(wms.length/2);
17651      wms.forEach(function(img, i) {
17652        var pos = pick(i < half);
17653        var w = Math.floor(Math.random()*60+80);
17654        var rot = (Math.random()*40-20).toFixed(1);
17655        var op = (Math.random()*0.08+0.05).toFixed(2);
17656        var animDur = (Math.random()*6+5).toFixed(1);
17657        var animDelay = (Math.random()*10).toFixed(1);
17658        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';
17659      });
17660    })();
17661  </script>
17662  <script nonce="{{ csp_nonce }}">
17663  (function(){
17664    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'}];
17665    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);});}
17666    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17667    function init(){
17668      var btn=document.getElementById('settings-btn');if(!btn)return;
17669      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17670      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>';
17671      document.body.appendChild(m);
17672      var g=document.getElementById('scheme-grid');
17673      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);});
17674      var cl=document.getElementById('settings-close');
17675      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);
17676      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');});
17677      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17678      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17679    }
17680    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17681  }());
17682  </script>
17683  <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>
17684</body>
17685</html>
17686"##,
17687    ext = "html"
17688)]
17689struct ErrorTemplate {
17690    message: String,
17691    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
17692    last_report_url: Option<String>,
17693    /// Label for the secondary action button; defaults to "View last report" when None.
17694    last_report_label: Option<String>,
17695    csp_nonce: String,
17696    version: &'static str,
17697}
17698
17699// ── RelocateScanTemplate ──────────────────────────────────────────────────────
17700
17701#[derive(Template)]
17702#[template(
17703    source = r##"
17704<!doctype html>
17705<html lang="en">
17706<head>
17707  <meta charset="utf-8">
17708  <meta name="viewport" content="width=device-width, initial-scale=1">
17709  <title>OxideSLOC | Locate Scan Files</title>
17710  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17711  <style nonce="{{ csp_nonce }}">
17712    :root {
17713      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17714      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17715      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17716      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17717    }
17718    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17719    *{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;}
17720    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17721    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17722    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
17723    .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);}
17724    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17725    .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));}
17726    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17727    .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;}
17728    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17729    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
17730    @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;}}
17731    .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;}
17732    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17733    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17734    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17735    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17736    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17737    .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;}
17738    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17739    .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);}
17740    .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;}
17741    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17742    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17743    .settings-modal-body{padding:14px 16px 16px;}
17744    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17745    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17746    .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;}
17747    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17748    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17749    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17750    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17751    .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;}
17752    .tz-select:focus{border-color:var(--oxide);}
17753    .page{max-width:860px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
17754    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
17755    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
17756    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
17757    .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;}
17758    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
17759    .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;}
17760    .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;}
17761    .btn-secondary:hover{background:var(--line);}
17762    .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;}
17763    .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;}
17764    .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;}
17765    @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));}}
17766    .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;}
17767    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
17768    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
17769    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
17770    .relocate-row{display:flex;gap:8px;align-items:stretch;}
17771    .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;}
17772    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
17773    body.dark-theme .relocate-input{background:var(--surface-2);}
17774  </style>
17775</head>
17776<body>
17777  <div class="background-watermarks" aria-hidden="true">
17778    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17779    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17780    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17781    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17782    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17783    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17784  </div>
17785  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17786  <div class="top-nav">
17787    <div class="top-nav-inner">
17788      <a class="brand" href="/">
17789        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17790        <div class="brand-copy">
17791          <div class="brand-title">OxideSLOC</div>
17792          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17793        </div>
17794      </a>
17795      <div class="nav-right">
17796        <a class="nav-pill" href="/">Home</a>
17797        <div class="nav-dropdown">
17798          <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>
17799          <div class="nav-dropdown-menu">
17800            <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>
17801          </div>
17802        </div>
17803        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
17804        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17805        <div class="nav-dropdown">
17806          <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>
17807          <div class="nav-dropdown-menu">
17808            <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>
17809          </div>
17810        </div>
17811        <div class="server-status-wrap" id="server-status-wrap">
17812          <div class="nav-pill server-online-pill" id="server-status-pill">
17813            <span class="status-dot" id="status-dot"></span>
17814            <span id="server-status-label">Server</span>
17815            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17816          </div>
17817          <div class="server-status-tip">
17818            OxideSLOC is running — accessible on your network.
17819            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17820          </div>
17821        </div>
17822        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17823          <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>
17824        </button>
17825        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17826          <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>
17827          <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>
17828        </button>
17829      </div>
17830    </div>
17831  </div>
17832
17833  <div class="page">
17834    <div class="panel">
17835      <h1>Scan Files Moved</h1>
17836      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
17837      <div class="error-box">{{ message }}</div>
17838      <div class="relocate-section">
17839        <h2>Locate Scan Output</h2>
17840        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
17841        <form method="post" action="/relocate-scan">
17842          <input type="hidden" name="run_id" value="{{ run_id }}">
17843          <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
17844          <div class="relocate-row">
17845            <input type="text" id="relocate-folder" name="folder_path"
17846                   value="{{ folder_hint }}"
17847                   placeholder="Path to folder containing scan output..."
17848                   class="relocate-input" autocomplete="off" spellcheck="false">
17849            {% if !server_mode %}
17850            <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
17851            {% endif %}
17852          </div>
17853          <div style="margin-top:12px;">
17854            <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
17855          </div>
17856        </form>
17857      </div>
17858      <div class="actions">
17859        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
17860        <a class="btn-secondary" href="/view-reports">View Reports</a>
17861      </div>
17862    </div>
17863  </div>
17864  <script nonce="{{ csp_nonce }}">
17865    (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");});})();
17866    (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);}})();
17867    (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';});})();
17868  </script>
17869  <script nonce="{{ csp_nonce }}">
17870  (function(){
17871    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'}];
17872    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);});}
17873    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17874    function init(){
17875      var btn=document.getElementById('settings-btn');if(!btn)return;
17876      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17877      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>';
17878      document.body.appendChild(m);
17879      var g=document.getElementById('scheme-grid');
17880      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);});
17881      var cl=document.getElementById('settings-close');
17882      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);
17883      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');});
17884      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17885      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17886    }
17887    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17888  }());
17889  (function(){
17890    var btn=document.getElementById('browse-relocate-btn');
17891    if(!btn)return;
17892    btn.addEventListener('click',function(){
17893      btn.disabled=true;btn.textContent='...';
17894      var inp=document.getElementById('relocate-folder');
17895      var hint=inp?inp.value:'';
17896      fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
17897        .then(function(r){return r.ok?r.json():{cancelled:true};})
17898        .then(function(d){
17899          btn.disabled=false;btn.textContent='Browse…';
17900          if(d&&d.selected_path&&inp)inp.value=d.selected_path;
17901        })
17902        .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
17903    });
17904  }());
17905  </script>
17906  <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>
17907</body>
17908</html>
17909"##,
17910    ext = "html"
17911)]
17912struct RelocateScanTemplate {
17913    message: String,
17914    run_id: String,
17915    folder_hint: String,
17916    redirect_url: String,
17917    server_mode: bool,
17918    csp_nonce: String,
17919    version: &'static str,
17920}
17921
17922// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
17923
17924#[derive(Template)]
17925#[template(
17926    source = r##"
17927<!doctype html>
17928<html lang="en">
17929<head>
17930  <meta charset="utf-8">
17931  <meta name="viewport" content="width=device-width, initial-scale=1">
17932  <title>OxideSLOC | View Reports</title>
17933  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17934  <style nonce="{{ csp_nonce }}">
17935    :root {
17936      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
17937      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17938      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
17939      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17940      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
17941    }
17942    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; }
17943    *{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;}
17944    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17945    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17946    .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);}
17947    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17948    .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));}
17949    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17950    .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;}
17951    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17952    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17953    @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; } }
17954    .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;}
17955    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17956    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17957    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17958    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17959    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17960    .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;}
17961    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17962    .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);}
17963    .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;}
17964    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17965    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17966    .settings-modal-body{padding:14px 16px 16px;}
17967    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17968    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17969    .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;}
17970    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17971    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17972    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17973    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17974    .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;}
17975    .tz-select:focus{border-color:var(--oxide);}
17976    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
17977    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
17978    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
17979    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
17980    .panel-meta{font-size:13px;color:var(--muted);}
17981    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
17982    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
17983    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
17984    .per-page-label{font-size:13px;color:var(--muted);}
17985    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;}
17986    .filter-input{min-width:180px;cursor:text;}
17987    .table-wrap{width:100%;overflow-x:auto;}
17988    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
17989    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;}
17990    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
17991    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
17992    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
17993    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
17994    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
17995    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
17996    tr:last-child td{border-bottom:none;}
17997    tr:hover td{background:var(--surface-2);}
17998    .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);}
17999    .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);}
18000    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18001    .metric-num{font-weight:700;color:var(--text);}
18002    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18003    .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;}
18004    .btn:hover{background:var(--line);}
18005    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18006    .btn.primary:hover{opacity:.9;}
18007    .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;}
18008    .btn-back:hover{background:var(--line);}
18009    .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;}
18010    .export-btn:hover{background:var(--line);}
18011    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
18012    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
18013    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
18014    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18015    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18016    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18017    .pagination-info{font-size:13px;color:var(--muted);}
18018    .pagination-btns{display:flex;gap:6px;}
18019    .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;}
18020    .pg-btn:hover:not(:disabled){background:var(--line);}
18021    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18022    .pg-btn:disabled{opacity:.35;cursor:default;}
18023    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18024    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18025    .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;}
18026    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18027    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18028    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18029    .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);}
18030    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18031    .stat-chip:hover .stat-chip-tip{opacity:1;}
18032    .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;}
18033    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18034    .site-footer a{color:var(--muted);}
18035    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18036    .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%;}
18037    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
18038    .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;}
18039    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18040    .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;}
18041    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18042    .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;}
18043    .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;}
18044    .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;}
18045    @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));}}
18046    .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;}
18047    .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;}
18048    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18049    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18050    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18051    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18052    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18053    .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;}
18054    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18055    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18056    .watched-chip-rm:hover{color:var(--oxide);}
18057    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18058    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18059    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18060    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18061    .rpt-btn{min-width:58px;justify-content:center;}
18062    .flex-row{display:flex;align-items:center;gap:8px;}
18063    .report-cell{overflow:visible;white-space:normal;}
18064    #history-table col:nth-child(1){width:185px;}
18065    #history-table col:nth-child(2){width:220px;}
18066    #history-table col:nth-child(3){width:100px;}
18067    #history-table col:nth-child(4){width:72px;}
18068    #history-table col:nth-child(5){width:82px;}
18069    #history-table col:nth-child(6){width:82px;}
18070    #history-table col:nth-child(7){width:65px;}
18071    #history-table col:nth-child(8){width:90px;}
18072    #history-table col:nth-child(9){width:85px;}
18073    #history-table col:nth-child(10){width:115px;}
18074    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
18075    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
18076    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
18077    .submod-details summary::-webkit-details-marker{display:none;}
18078.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
18079    .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;}
18080    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
18081    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
18082  </style>
18083</head>
18084<body>
18085  <div class="background-watermarks" aria-hidden="true">
18086    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18087    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18088    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18089    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18090    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18091    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18092  </div>
18093  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18094  <div class="top-nav">
18095    <div class="top-nav-inner">
18096      <a class="brand" href="/">
18097        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18098        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
18099      </a>
18100      <div class="nav-right">
18101        <a class="nav-pill" href="/">Home</a>
18102        <div class="nav-dropdown">
18103          <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>
18104          <div class="nav-dropdown-menu">
18105            <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>
18106          </div>
18107        </div>
18108        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18109        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18110        <div class="nav-dropdown">
18111          <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>
18112          <div class="nav-dropdown-menu">
18113            <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>
18114          </div>
18115        </div>
18116        <div class="server-status-wrap" id="server-status-wrap">
18117          <div class="nav-pill server-online-pill" id="server-status-pill">
18118            <span class="status-dot" id="status-dot"></span>
18119            <span id="server-status-label">Server</span>
18120            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18121          </div>
18122          <div class="server-status-tip">
18123            OxideSLOC is running — accessible on your network.
18124            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18125          </div>
18126        </div>
18127        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18128          <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>
18129        </button>
18130        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18131          <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>
18132          <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>
18133        </button>
18134      </div>
18135    </div>
18136  </div>
18137
18138  <div class="page">
18139    {% if let Some(err) = browse_error %}
18140    <div class="toast-error">
18141      <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>
18142      {{ err }}
18143    </div>
18144    {% endif %}
18145    {% if linked_count > 0 %}
18146    <div class="toast-success">
18147      <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>
18148      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
18149    </div>
18150    {% endif %}
18151    <div class="watched-bar">
18152      <div class="watched-bar-left">
18153        <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>
18154        <span class="watched-label">Watched Folders</span>
18155        <div class="watched-chips">
18156          {% if server_mode %}
18157          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18158          {% else %}
18159          {% for dir in watched_dirs %}
18160          <span class="watched-chip">
18161            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18162            <form method="POST" action="/watched-dirs/remove" style="display:contents">
18163              <input type="hidden" name="folder_path" value="{{ dir }}">
18164              <input type="hidden" name="redirect_to" value="/view-reports">
18165              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
18166            </form>
18167          </span>
18168          {% endfor %}
18169          {% if watched_dirs.is_empty() %}
18170          <span class="watched-none">No folders watched — click Choose to add one</span>
18171          {% endif %}
18172          {% endif %}
18173        </div>
18174      </div>
18175      {% if !server_mode %}
18176      <div class="watched-bar-right">
18177        <button type="button" class="btn" id="add-watched-btn">
18178          <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>
18179          Choose
18180        </button>
18181        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18182          <input type="hidden" name="redirect_to" value="/view-reports">
18183          <button type="submit" class="btn">&#8635; Refresh</button>
18184        </form>
18185      </div>
18186      {% endif %}
18187    </div>
18188    {% if total_scans > 0 %}
18189    <div class="summary-strip">
18190      <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>
18191      <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>
18192      <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>
18193      <div class="stat-chip"><div class="stat-chip-tip">Files excluded by policy rules (vendor, generated, binary, lockfiles, etc.) in the most recent scan</div><div class="stat-chip-val" id="agg-skipped">—</div><div class="stat-chip-label">Latest files skipped</div></div>
18194    </div>
18195    {% endif %}
18196
18197    <section class="panel">
18198      <div class="panel-header">
18199        <div>
18200          <h1>View Reports</h1>
18201          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
18202          {% 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 %}
18203        </div>
18204        <div class="flex-row">
18205          <button type="button" class="export-btn" id="export-csv-btn">
18206            <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>
18207            Export CSV
18208          </button>
18209          <button type="button" class="export-btn" id="export-xls-btn">
18210            <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>
18211            Export Excel
18212          </button>
18213        </div>
18214      </div>
18215
18216      {% if entries.is_empty() %}
18217      <div class="empty-state">
18218        <strong>No reports with viewable HTML yet</strong>
18219        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.
18220      </div>
18221      {% else %}
18222      <div class="filter-row">
18223        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
18224        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18225        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
18226      </div>
18227      <div class="table-wrap">
18228        <table id="history-table">
18229          <colgroup>
18230            <col><col><col><col><col><col><col><col><col><col>
18231          </colgroup>
18232          <thead>
18233            <tr id="history-thead">
18234              <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>
18235              <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>
18236              <th>Run ID<div class="col-resize-handle"></div></th>
18237              <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>
18238              <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>
18239              <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>
18240              <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>
18241              <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>
18242              <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>
18243              <th>Report<div class="col-resize-handle"></div></th>
18244            </tr>
18245          </thead>
18246          <tbody id="history-tbody">
18247            {% for entry in entries %}
18248            <tr class="history-row" data-run="{{ entry.run_id }}"
18249                data-timestamp="{{ entry.timestamp }}"
18250                data-project="{{ entry.project_label }}"
18251                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
18252                data-skipped="{{ entry.files_skipped }}"
18253                data-comments="{{ entry.comment_lines }}"
18254                data-blank="{{ entry.blank_lines }}"
18255                data-branch="{{ entry.git_branch }}"
18256                data-commit="{{ entry.git_commit }}"
18257                data-html-url="/runs/html/{{ entry.run_id }}">
18258              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18259              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18260              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
18261              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
18262              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18263              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18264              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18265              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
18266              <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>
18267              <td class="report-cell">
18268                <div class="actions-cell">
18269                  {% 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 %}
18270                  {% 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 %}
18271                </div>
18272                {% if !entry.submodule_links.is_empty() %}
18273                <details class="submod-details">
18274                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
18275                  <div class="submod-link-list">
18276                    {% for sub in entry.submodule_links %}
18277                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
18278                    {% endfor %}
18279                  </div>
18280                </details>
18281                {% endif %}
18282              </td>
18283            </tr>
18284            {% endfor %}
18285          </tbody>
18286        </table>
18287      </div>
18288      <div class="pagination">
18289        <span class="pagination-info" id="pagination-info"></span>
18290        <div class="pagination-btns" id="pagination-btns"></div>
18291        <div class="flex-row">
18292          <span class="per-page-label">Show</span>
18293          <select class="per-page" id="per-page-sel">
18294            <option value="10">10 per page</option>
18295            <option value="25" selected>25 per page</option>
18296            <option value="50">50 per page</option>
18297            <option value="100">100 per page</option>
18298          </select>
18299          <span class="per-page-label" id="page-range-label"></span>
18300        </div>
18301      </div>
18302      {% endif %}
18303    </section>
18304  </div>
18305
18306  <footer class="site-footer">
18307    local code analysis - metrics, history and reports
18308    &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>
18309    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18310    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18311    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18312    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
18313  </footer>
18314
18315  <script nonce="{{ csp_nonce }}">
18316    (function () {
18317      // ── Theme ──────────────────────────────────────────────────────────────
18318      var storageKey = 'oxide-sloc-theme';
18319      var body = document.body;
18320      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
18321      var toggle = document.getElementById('theme-toggle');
18322      if (toggle) toggle.addEventListener('click', function () {
18323        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
18324        body.classList.toggle('dark-theme', next === 'dark');
18325        try { localStorage.setItem(storageKey, next); } catch(e) {}
18326      });
18327
18328      // ── State ─────────────────────────────────────────────────────────────
18329      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
18330      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
18331      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
18332
18333      // Aggregate stats from first (most recent) row
18334      if (allRows.length) {
18335        var first = allRows[0];
18336        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();}
18337        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>':'');}
18338        setChipVal('agg-code', first.dataset.code);
18339        setChipVal('agg-files', first.dataset.files);
18340        setChipVal('agg-skipped', first.dataset.skipped);
18341      }
18342
18343      // ── Branch filter population ──────────────────────────────────────────
18344      (function() {
18345        var branches = {};
18346        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
18347        var sel = document.getElementById('branch-filter');
18348        if (sel) Object.keys(branches).sort().forEach(function(b) {
18349          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
18350        });
18351      })();
18352
18353      // ── Filter ────────────────────────────────────────────────────────────
18354      function getFilteredRows() {
18355        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
18356        var branch = ((document.getElementById('branch-filter') || {}).value || '');
18357        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
18358          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
18359          if (branch && (r.dataset.branch || '') !== branch) return false;
18360          return true;
18361        });
18362      }
18363
18364      // ── Pagination ────────────────────────────────────────────────────────
18365      function renderPage() {
18366        var filtered = getFilteredRows();
18367        var total = filtered.length;
18368        var totalPages = Math.max(1, Math.ceil(total / perPage));
18369        currentPage = Math.min(currentPage, totalPages);
18370        var start = (currentPage - 1) * perPage;
18371        var end = Math.min(start + perPage, total);
18372        var shown = {};
18373        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
18374        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
18375          r.style.display = shown[r.dataset.run] ? '' : 'none';
18376        });
18377        var rl = document.getElementById('page-range-label');
18378        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
18379        var info = document.getElementById('pagination-info');
18380        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
18381        var btns = document.getElementById('pagination-btns');
18382        if (!btns) return;
18383        btns.innerHTML = '';
18384        function makeBtn(lbl, pg, active, disabled) {
18385          var b = document.createElement('button');
18386          b.className = 'pg-btn' + (active ? ' active' : '');
18387          b.textContent = lbl; b.disabled = disabled;
18388          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
18389          return b;
18390        }
18391        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
18392        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
18393        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
18394        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
18395      }
18396
18397      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
18398      window.applyFilters = function() { currentPage = 1; renderPage(); };
18399
18400      // ── Sorting ───────────────────────────────────────────────────────────
18401      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
18402      function doSort(col, type, order) {
18403        var tbody = document.getElementById('history-tbody');
18404        if (!tbody) return;
18405        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
18406        rows.sort(function(a, b) {
18407          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
18408          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
18409          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
18410          return va < vb ? 1 : va > vb ? -1 : 0;
18411        });
18412        rows.forEach(function(r) { tbody.appendChild(r); });
18413        currentPage = 1; renderPage();
18414      }
18415      sortHeaders.forEach(function(th) {
18416        th.addEventListener('click', function(e) {
18417          if (e.target.classList.contains('col-resize-handle')) return;
18418          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
18419          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
18420          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18421          th.classList.add('sort-' + sortOrder);
18422          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
18423          doSort(col, type, sortOrder);
18424        });
18425      });
18426
18427      // ── Column resize ─────────────────────────────────────────────────────
18428      (function() {
18429        var table = document.getElementById('history-table');
18430        if (!table) return;
18431        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
18432        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
18433        ths.forEach(function(th, i) {
18434          var handle = th.querySelector('.col-resize-handle');
18435          if (!handle || !cols[i]) return;
18436          var startX, startW;
18437          handle.addEventListener('mousedown', function(e) {
18438            e.stopPropagation(); e.preventDefault();
18439            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
18440            handle.classList.add('dragging');
18441            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
18442            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
18443            document.addEventListener('mousemove', onMove);
18444            document.addEventListener('mouseup', onUp);
18445          });
18446        });
18447      })();
18448
18449      // ── Reset view ────────────────────────────────────────────────────────
18450      window.resetView = function() {
18451        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
18452        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
18453        sortCol = null; sortOrder = 'asc';
18454        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18455        var tbody = document.getElementById('history-tbody');
18456        if (tbody) {
18457          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
18458          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
18459          rows.forEach(function(r) { tbody.appendChild(r); });
18460        }
18461        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
18462        var table = document.getElementById('history-table');
18463        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
18464        currentPage = 1; renderPage();
18465      };
18466
18467      renderPage();
18468
18469      // ── Export helpers ────────────────────────────────────────────────────
18470      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
18471      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
18472      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);}
18473      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;');}
18474      function slocXlsx(fname,sheet,hdrs,rows){
18475        var enc=new TextEncoder();
18476        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;}
18477        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;}
18478        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
18479        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
18480        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18481        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;}
18482        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];}
18483        var rx='<row r="1">';
18484        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
18485        rx+='</row>';
18486        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>';});
18487        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
18488        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>';
18489        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>';
18490        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>';
18491        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>',
18492          '_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>',
18493          '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>',
18494          '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>',
18495          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
18496        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'];
18497        var zparts=[],zcds=[],zoff=0,znf=0;
18498        order.forEach(function(name){
18499          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
18500          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]);
18501          var entry=new Uint8Array(lha.length+nb.length+sz);
18502          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
18503          zparts.push(entry);
18504          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));
18505          var cde=new Uint8Array(cda.length+nb.length);
18506          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
18507          zcds.push(cde);zoff+=entry.length;znf++;
18508        });
18509        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
18510        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]);
18511        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
18512        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
18513        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
18514        zout.set(new Uint8Array(ea),zpos);
18515        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
18516      }
18517
18518      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
18519      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;}
18520      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
18521      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
18522
18523      var csvBtn = document.getElementById('export-csv-btn');
18524      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
18525      var xlsBtn = document.getElementById('export-xls-btn');
18526      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
18527
18528      // ── Remaining CSP-safe event bindings ────────────────────────────────
18529      (function wireEvents() {
18530        var el;
18531        el = document.getElementById('reset-view-btn');
18532        if (el) el.addEventListener('click', window.resetView);
18533        el = document.getElementById('project-filter');
18534        if (el) el.addEventListener('input', window.applyFilters);
18535        el = document.getElementById('branch-filter');
18536        if (el) el.addEventListener('change', window.applyFilters);
18537        el = document.getElementById('per-page-sel');
18538        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
18539        el = document.getElementById('add-watched-btn');
18540        if (el) el.addEventListener('click', function() {
18541          fetch('/pick-directory?kind=reports')
18542            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
18543            .then(function(data) {
18544              if (!data.cancelled && data.selected_path) {
18545                var form = document.createElement('form');
18546                form.method = 'POST';
18547                form.action = '/watched-dirs/add';
18548                var ri = document.createElement('input');
18549                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
18550                var fi = document.createElement('input');
18551                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
18552                form.appendChild(ri); form.appendChild(fi);
18553                document.body.appendChild(form);
18554                form.submit();
18555              }
18556            })
18557            .catch(function(e) { alert('Could not open folder picker: ' + e); });
18558        });
18559      })();
18560
18561      (function randomizeWatermarks() {
18562        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18563        if (!wms.length) return;
18564        var placed = [];
18565        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;}
18566        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];}
18567        var half=Math.floor(wms.length/2);
18568        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;});
18569      })();
18570
18571      (function spawnCodeParticles() {
18572        var container = document.getElementById('code-particles');
18573        if (!container) return;
18574        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'];
18575        for (var i = 0; i < 38; i++) {
18576          (function(idx) {
18577            var el = document.createElement('span');
18578            el.className = 'code-particle';
18579            el.textContent = snippets[idx % snippets.length];
18580            var left = Math.random() * 94 + 2;
18581            var top = Math.random() * 88 + 6;
18582            var dur = (Math.random() * 10 + 9).toFixed(1);
18583            var delay = (Math.random() * 18).toFixed(1);
18584            var rot = (Math.random() * 26 - 13).toFixed(1);
18585            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18586            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';
18587            container.appendChild(el);
18588          })(i);
18589        }
18590      })();
18591    })();
18592  </script>
18593  <script nonce="{{ csp_nonce }}">
18594  (function(){
18595    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'}];
18596    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);});}
18597    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18598    function init(){
18599      var btn=document.getElementById('settings-btn');if(!btn)return;
18600      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18601      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>';
18602      document.body.appendChild(m);
18603      var g=document.getElementById('scheme-grid');
18604      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);});
18605      var cl=document.getElementById('settings-close');
18606      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);
18607      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');});
18608      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18609      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18610    }
18611    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18612  }());
18613  </script>
18614  <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>
18615</body>
18616</html>
18617"##,
18618    ext = "html"
18619)]
18620struct HistoryTemplate {
18621    version: &'static str,
18622    entries: Vec<HistoryEntryRow>,
18623    total_scans: usize,
18624    linked_count: usize,
18625    browse_error: Option<String>,
18626    watched_dirs: Vec<String>,
18627    csp_nonce: String,
18628    server_mode: bool,
18629}
18630
18631// ── CompareSelectTemplate ──────────────────────────────────────────────────────
18632
18633#[derive(Template)]
18634#[template(
18635    source = r##"
18636<!doctype html>
18637<html lang="en">
18638<head>
18639  <meta charset="utf-8">
18640  <meta name="viewport" content="width=device-width, initial-scale=1">
18641  <title>OxideSLOC | Compare Scans</title>
18642  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18643  <style nonce="{{ csp_nonce }}">
18644    :root {
18645      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18646      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18647      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18648      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18649      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
18650    }
18651    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18652    *{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;}
18653    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18654    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18655    .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);}
18656    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18657    .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));}
18658    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18659    .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;}
18660    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18661    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18662    @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; } }
18663    .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;}
18664    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18665    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18666    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18667    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18668    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18669    .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;}
18670    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18671    .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);}
18672    .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;}
18673    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18674    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18675    .settings-modal-body{padding:14px 16px 16px;}
18676    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18677    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18678    .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;}
18679    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18680    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18681    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18682    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18683    .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;}
18684    .tz-select:focus{border-color:var(--oxide);}
18685    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18686    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18687    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18688    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18689    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
18690    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
18691    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18692    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18693    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18694    .per-page-label{font-size:13px;color:var(--muted);}
18695    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;}
18696    .filter-input{min-width:180px;cursor:text;}
18697    .table-wrap{width:100%;overflow-x:auto;}
18698    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
18699    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;}
18700    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18701    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18702    #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;}
18703    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
18704    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
18705    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
18706    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
18707    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
18708    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
18709    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
18710    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
18711    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
18712    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18713    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18714    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18715    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18716    tr:last-child td{border-bottom:none;}
18717    tr.selected td{background:var(--sel-bg);}
18718    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
18719    tr:hover:not(.selected) td{background:var(--surface-2);}
18720    tr{cursor:pointer;}
18721    .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);}
18722    .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);}
18723    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18724    .metric-num{font-weight:700;color:var(--text);}
18725    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18726    .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;}
18727    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
18728    .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;}
18729    .btn:hover{background:var(--line);}
18730    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
18731    .btn.primary:hover{opacity:.9;}
18732    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
18733    .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;}
18734    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18735    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18736    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18737    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18738    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18739    .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;}
18740    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18741    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18742    .watched-chip-rm:hover{color:var(--oxide);}
18743    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18744    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18745    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18746    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18747    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
18748    .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;}
18749    .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;}
18750    .btn-back:hover{background:var(--line);}
18751    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18752    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18753    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18754    .pagination-info{font-size:13px;color:var(--muted);}
18755    .pagination-btns{display:flex;gap:6px;}
18756    .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;}
18757    .pg-btn:hover:not(:disabled){background:var(--line);}
18758    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18759    .pg-btn:disabled{opacity:.35;cursor:default;}
18760    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
18761    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18762    .site-footer a{color:var(--muted);}
18763    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18764    .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;}
18765    .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;}
18766    .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;}
18767    @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));}}
18768    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18769    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18770    .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;}
18771    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18772    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18773    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18774    .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);}
18775    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18776    .stat-chip:hover .stat-chip-tip{opacity:1;}
18777    .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;}
18778    .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;}
18779    .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%;}
18780    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
18781    .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;}
18782    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
18783    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
18784    .hidden{display:none!important;}
18785    .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%;}
18786    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
18787    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
18788    .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;}
18789    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
18790    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
18791    .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;}
18792    .scope-option:hover{background:var(--line);}
18793    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
18794    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
18795    .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;}
18796    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
18797    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
18798    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
18799    .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;}
18800  </style>
18801</head>
18802<body>
18803  <div class="background-watermarks" aria-hidden="true">
18804    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18805    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18806    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18807    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18808    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18809    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18810  </div>
18811  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18812  <div class="top-nav">
18813    <div class="top-nav-inner">
18814      <a class="brand" href="/">
18815        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18816        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
18817      </a>
18818      <div class="nav-right">
18819        <a class="nav-pill" href="/">Home</a>
18820        <div class="nav-dropdown">
18821          <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>
18822          <div class="nav-dropdown-menu">
18823            <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>
18824          </div>
18825        </div>
18826        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18827        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18828        <div class="nav-dropdown">
18829          <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>
18830          <div class="nav-dropdown-menu">
18831            <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>
18832          </div>
18833        </div>
18834        <div class="server-status-wrap" id="server-status-wrap">
18835          <div class="nav-pill server-online-pill" id="server-status-pill">
18836            <span class="status-dot" id="status-dot"></span>
18837            <span id="server-status-label">Server</span>
18838            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18839          </div>
18840          <div class="server-status-tip">
18841            OxideSLOC is running — accessible on your network.
18842            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18843          </div>
18844        </div>
18845        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18846          <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>
18847        </button>
18848        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18849          <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>
18850          <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>
18851        </button>
18852      </div>
18853    </div>
18854  </div>
18855
18856  <div class="page">
18857    <div class="watched-bar">
18858      <div class="watched-bar-left">
18859        <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>
18860        <span class="watched-label">Watched Folders</span>
18861        <div class="watched-chips">
18862          {% if server_mode %}
18863          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18864          {% else %}
18865          {% for dir in watched_dirs %}
18866          <span class="watched-chip">
18867            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18868            <form method="POST" action="/watched-dirs/remove" style="display:contents">
18869              <input type="hidden" name="folder_path" value="{{ dir }}">
18870              <input type="hidden" name="redirect_to" value="/compare-scans">
18871              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
18872            </form>
18873          </span>
18874          {% endfor %}
18875          {% if watched_dirs.is_empty() %}
18876          <span class="watched-none">No folders watched — click Choose to add one</span>
18877          {% endif %}
18878          {% endif %}
18879        </div>
18880      </div>
18881      {% if !server_mode %}
18882      <div class="watched-bar-right">
18883        <button type="button" class="btn" id="add-watched-btn">
18884          <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>
18885          Choose
18886        </button>
18887        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18888          <input type="hidden" name="redirect_to" value="/compare-scans">
18889          <button type="submit" class="btn">&#8635; Refresh</button>
18890        </form>
18891      </div>
18892      {% endif %}
18893    </div>
18894    {% if total_scans > 0 %}
18895    <div class="summary-strip">
18896      <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>
18897      <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>
18898      <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>
18899      <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>
18900    </div>
18901    {% endif %}
18902    <section class="panel">
18903      <div class="panel-header">
18904        <div>
18905          <h1>Compare Scans</h1>
18906          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
18907        </div>
18908        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
18909          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
18910            <button class="btn primary" id="compare-btn" disabled>
18911              <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>
18912              Compare <span class="sel-count" id="sel-count">0/2</span>
18913            </button>
18914          </div>
18915        </div>
18916      </div>
18917
18918      {% if entries.is_empty() %}
18919      <div class="empty-state">
18920        <strong>No scans yet</strong>
18921        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.
18922      </div>
18923      {% else %}
18924      <div class="filter-row">
18925        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
18926        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18927        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
18928      </div>
18929      <div class="scope-panel hidden" id="scope-panel">
18930        <div class="scope-panel-label">
18931          <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>
18932          Compare scope — choose what to include
18933        </div>
18934        <div class="scope-options" id="scope-options"></div>
18935      </div>
18936      {% if total_scans > 0 %}
18937      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
18938        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
18939          <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>
18940          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
18941        </div>
18942      </div>
18943      {% endif %}
18944      <div class="table-wrap">
18945        <table id="compare-table">
18946          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
18947          <thead>
18948            <tr id="compare-thead">
18949              <th><div class="col-resize-handle"></div></th>
18950              <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>
18951              <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>
18952              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
18953              <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>
18954              <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>
18955              <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>
18956              <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>
18957              <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>
18958              <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>
18959              <th>Submodules<div class="col-resize-handle"></div></th>
18960            </tr>
18961          </thead>
18962          <tbody id="compare-tbody">
18963            {% for entry in entries %}
18964            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
18965                data-timestamp="{{ entry.timestamp }}"
18966                data-project="{{ entry.project_label }}"
18967                data-files="{{ entry.files_analyzed }}"
18968                data-code="{{ entry.code_lines }}"
18969                data-comments="{{ entry.comment_lines }}"
18970                data-blank="{{ entry.blank_lines }}"
18971                data-branch="{{ entry.git_branch }}"
18972                data-commit="{{ entry.git_commit }}"
18973                data-submodules="{{ entry.submodule_names_csv }}">
18974              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
18975              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18976              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18977              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
18978              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
18979              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18980              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18981              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18982              <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>
18983              <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>
18984              <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>
18985            </tr>
18986            {% endfor %}
18987          </tbody>
18988        </table>
18989      </div>
18990      <div class="pagination">
18991        <span class="pagination-info" id="pagination-info"></span>
18992        <div class="pagination-btns" id="pagination-btns"></div>
18993        <div class="flex-row">
18994          <span class="per-page-label">Show</span>
18995          <select class="per-page" id="per-page-sel">
18996            <option value="10">10 per page</option>
18997            <option value="25" selected>25 per page</option>
18998            <option value="50">50 per page</option>
18999            <option value="100">100 per page</option>
19000          </select>
19001          <span class="per-page-label" id="page-range-label"></span>
19002        </div>
19003      </div>
19004      {% endif %}
19005    </section>
19006  </div>
19007
19008  <footer class="site-footer">
19009    local code analysis - metrics, history and reports
19010    &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>
19011    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19012    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19013    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19014    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19015  </footer>
19016
19017  <script nonce="{{ csp_nonce }}">
19018    (function () {
19019      // ── Theme ──────────────────────────────────────────────────────────────
19020      var storageKey = 'oxide-sloc-theme';
19021      var body = document.body;
19022      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19023      var toggle = document.getElementById('theme-toggle');
19024      if (toggle) toggle.addEventListener('click', function () {
19025        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19026        body.classList.toggle('dark-theme', next === 'dark');
19027        try { localStorage.setItem(storageKey, next); } catch(e) {}
19028      });
19029
19030      // ── State ─────────────────────────────────────────────────────────────
19031      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
19032      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
19033      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
19034
19035      // ── Stat chips ────────────────────────────────────────────────────────
19036      (function() {
19037        var projects = {}, latestTs = '', latestRow = null;
19038        allRows.forEach(function(r) {
19039          var p = r.dataset.project || ''; if (p) projects[p] = true;
19040          var ts = r.dataset.timestamp || '';
19041          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
19042        });
19043        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();}
19044        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>':'');}
19045        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
19046        if (latestRow) {
19047          setChipVal('agg-code', latestRow.dataset.code);
19048          setChipVal('agg-files', latestRow.dataset.files);
19049        }
19050      })();
19051
19052      // ── Branch filter population ──────────────────────────────────────────
19053      (function() {
19054        var branches = {};
19055        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
19056        var sel = document.getElementById('branch-filter');
19057        if (sel) Object.keys(branches).sort().forEach(function(b) {
19058          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
19059        });
19060      })();
19061
19062      // ── Filter ────────────────────────────────────────────────────────────
19063      function getFilteredRows() {
19064        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
19065        var branch = ((document.getElementById('branch-filter') || {}).value || '');
19066        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
19067          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
19068          if (branch && (r.dataset.branch || '') !== branch) return false;
19069          return true;
19070        });
19071      }
19072
19073      // ── Pagination ────────────────────────────────────────────────────────
19074      function renderPage() {
19075        var filtered = getFilteredRows();
19076        var total = filtered.length;
19077        var totalPages = Math.max(1, Math.ceil(total / perPage));
19078        currentPage = Math.min(currentPage, totalPages);
19079        var start = (currentPage - 1) * perPage;
19080        var end = Math.min(start + perPage, total);
19081        var shown = {};
19082        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19083        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
19084          r.style.display = shown[r.dataset.run] ? '' : 'none';
19085        });
19086        var rl = document.getElementById('page-range-label');
19087        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19088        var info = document.getElementById('pagination-info');
19089        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19090        var btns = document.getElementById('pagination-btns');
19091        if (!btns) return;
19092        btns.innerHTML = '';
19093        function makeBtn(lbl, pg, active, disabled) {
19094          var b = document.createElement('button');
19095          b.className = 'pg-btn' + (active ? ' active' : '');
19096          b.textContent = lbl; b.disabled = disabled;
19097          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19098          return b;
19099        }
19100        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19101        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19102        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19103        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19104      }
19105
19106      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19107      window.applyFilters = function() { currentPage = 1; renderPage(); };
19108
19109      // ── Sorting ───────────────────────────────────────────────────────────
19110      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
19111      function doSort(col, type, order) {
19112        var tbody = document.getElementById('compare-tbody');
19113        if (!tbody) return;
19114        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19115        rows.sort(function(a, b) {
19116          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19117          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19118          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19119          return va < vb ? 1 : va > vb ? -1 : 0;
19120        });
19121        rows.forEach(function(r) { tbody.appendChild(r); });
19122        currentPage = 1; renderPage();
19123      }
19124      sortHeaders.forEach(function(th) {
19125        th.addEventListener('click', function(e) {
19126          if (e.target.classList.contains('col-resize-handle')) return;
19127          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19128          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19129          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19130          th.classList.add('sort-' + sortOrder);
19131          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19132          doSort(col, type, sortOrder);
19133        });
19134      });
19135
19136      // Apply default sort (timestamp desc) on initial load
19137      (function() {
19138        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
19139        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
19140      })();
19141
19142      // ── Column resize ─────────────────────────────────────────────────────
19143      (function() {
19144        var table = document.getElementById('compare-table');
19145        if (!table) return;
19146        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19147        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
19148        ths.forEach(function(th, i) {
19149          var handle = th.querySelector('.col-resize-handle');
19150          if (!handle || !cols[i]) return;
19151          var startX, startW;
19152          handle.addEventListener('mousedown', function(e) {
19153            e.stopPropagation(); e.preventDefault();
19154            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19155            handle.classList.add('dragging');
19156            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19157            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19158            document.addEventListener('mousemove', onMove);
19159            document.addEventListener('mouseup', onUp);
19160          });
19161        });
19162      })();
19163
19164      // ── Reset view ────────────────────────────────────────────────────────
19165      window.resetView = function() {
19166        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19167        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19168        sortCol = null; sortOrder = 'asc';
19169        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19170        var tbody = document.getElementById('compare-tbody');
19171        if (tbody) {
19172          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19173          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19174          rows.forEach(function(r) { tbody.appendChild(r); });
19175        }
19176        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19177        var table = document.getElementById('compare-table');
19178        currentPage = 1; renderPage();
19179        currentPage = 1; renderPage();
19180      };
19181
19182      renderPage();
19183
19184      // ── Row selection state ───────────────────────────────────────────────
19185      var selected = [];
19186      function updateCompareBtn() {
19187        var btn = document.getElementById('compare-btn');
19188        var cnt = document.getElementById('sel-count');
19189        if (!btn) return;
19190        btn.disabled = selected.length !== 2;
19191        if (cnt) cnt.textContent = selected.length + '/2';
19192      }
19193
19194      function toggleRow(row) {
19195        var vid = row.dataset.vid || row.dataset.run;
19196        var idx = selected.indexOf(vid);
19197        if (idx >= 0) {
19198          selected.splice(idx, 1);
19199          row.classList.remove('selected');
19200          var b = document.getElementById('badge-' + vid);
19201          if (b) b.textContent = '';
19202        } else {
19203          if (selected.length >= 2) return;
19204          selected.push(vid);
19205          row.classList.add('selected');
19206        }
19207        selected.forEach(function(v, i) {
19208          var b = document.getElementById('badge-' + v);
19209          if (b) b.textContent = i + 1;
19210        });
19211        updateCompareBtn();
19212        buildScopePanel();
19213      }
19214
19215      // ── Scope panel ───────────────────────────────────────────────────────
19216      var selectedScope = 'all';
19217
19218      function buildScopePanel() {
19219        var panel = document.getElementById('scope-panel');
19220        var opts = document.getElementById('scope-options');
19221        if (!panel || !opts) return;
19222        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19223
19224        // Collect union of submodules from both selected rows.
19225        var allSubs = {};
19226        selected.forEach(function(vid) {
19227          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
19228          if (!row) return;
19229          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
19230        });
19231        var subList = Object.keys(allSubs).sort();
19232        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19233
19234        panel.classList.remove('hidden');
19235        opts.innerHTML = '';
19236
19237        function makeOption(value, label, title) {
19238          var div = document.createElement('div');
19239          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
19240          div.dataset.scopeValue = value;
19241          if (title) div.title = title;
19242          var radio = document.createElement('span');
19243          radio.className = 'scope-option-radio';
19244          var lbl = document.createElement('span');
19245          lbl.textContent = label;
19246          div.appendChild(radio);
19247          div.appendChild(lbl);
19248          div.addEventListener('click', function() {
19249            selectedScope = value;
19250            opts.querySelectorAll('.scope-option').forEach(function(o) {
19251              o.classList.toggle('selected', o.dataset.scopeValue === value);
19252            });
19253          });
19254          return div;
19255        }
19256
19257        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
19258        var sep = document.createElement('span');
19259        sep.className = 'scope-option-sep';
19260        opts.appendChild(sep);
19261        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
19262        subList.forEach(function(s) {
19263          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
19264        });
19265      }
19266
19267      function doCompare() {
19268        if (selected.length !== 2) return;
19269        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
19270        if (selectedScope === 'super') url += '&scope=super';
19271        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
19272        window.location.href = url;
19273      }
19274
19275      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
19276      var cbtn = document.getElementById('compare-btn');
19277      if (cbtn) cbtn.addEventListener('click', doCompare);
19278      var pfEl = document.getElementById('project-filter');
19279      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
19280      var bfEl = document.getElementById('branch-filter');
19281      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
19282      var rvBtn = document.getElementById('reset-view-btn');
19283      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
19284      var ppSel = document.getElementById('per-page-sel');
19285      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
19286
19287      var cmpTbody = document.getElementById('compare-tbody');
19288      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
19289        var row = e.target.closest('.compare-row');
19290        if (row) toggleRow(row);
19291      });
19292
19293      (function randomizeWatermarks() {
19294        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19295        if (!wms.length) return;
19296        var placed = [];
19297        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;}
19298        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];}
19299        var half=Math.floor(wms.length/2);
19300        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;});
19301      })();
19302
19303      (function spawnCodeParticles() {
19304        var container = document.getElementById('code-particles');
19305        if (!container) return;
19306        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'];
19307        for (var i = 0; i < 38; i++) {
19308          (function(idx) {
19309            var el = document.createElement('span');
19310            el.className = 'code-particle';
19311            el.textContent = snippets[idx % snippets.length];
19312            var left = Math.random() * 94 + 2;
19313            var top = Math.random() * 88 + 6;
19314            var dur = (Math.random() * 10 + 9).toFixed(1);
19315            var delay = (Math.random() * 18).toFixed(1);
19316            var rot = (Math.random() * 26 - 13).toFixed(1);
19317            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19318            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';
19319            container.appendChild(el);
19320          })(i);
19321        }
19322      })();
19323
19324      // ── Watched folder picker ─────────────────────────────────────────────
19325      (function() {
19326        var btn = document.getElementById('add-watched-btn');
19327        if (!btn) return;
19328        btn.addEventListener('click', function() {
19329          fetch('/pick-directory?kind=reports')
19330            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19331            .then(function(data) {
19332              if (!data.cancelled && data.selected_path) {
19333                var form = document.createElement('form');
19334                form.method = 'POST';
19335                form.action = '/watched-dirs/add';
19336                var ri = document.createElement('input');
19337                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19338                var fi = document.createElement('input');
19339                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19340                form.appendChild(ri); form.appendChild(fi);
19341                document.body.appendChild(form);
19342                form.submit();
19343              }
19344            })
19345            .catch(function(e) { alert('Could not open folder picker: ' + e); });
19346        });
19347      })();
19348
19349      // ── Submodule chip truncation ─────────────────────────────────────────
19350      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
19351        var chips = cell.querySelectorAll('.submod-chip');
19352        var MAX = 4;
19353        if (chips.length <= MAX) return;
19354        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
19355        var badge = document.createElement('span');
19356        badge.className = 'submod-overflow-badge';
19357        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
19358        badge.textContent = '+' + (chips.length - MAX) + ' more';
19359        cell.appendChild(badge);
19360        cell.style.maxHeight = 'none';
19361      });
19362    })();
19363  </script>
19364  <script nonce="{{ csp_nonce }}">
19365  (function(){
19366    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'}];
19367    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);});}
19368    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19369    function init(){
19370      var btn=document.getElementById('settings-btn');if(!btn)return;
19371      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19372      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>';
19373      document.body.appendChild(m);
19374      var g=document.getElementById('scheme-grid');
19375      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);});
19376      var cl=document.getElementById('settings-close');
19377      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);
19378      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');});
19379      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19380      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19381    }
19382    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19383  }());
19384  </script>
19385  <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>
19386</body>
19387</html>
19388"##,
19389    ext = "html"
19390)]
19391struct CompareSelectTemplate {
19392    version: &'static str,
19393    entries: Vec<HistoryEntryRow>,
19394    total_scans: usize,
19395    watched_dirs: Vec<String>,
19396    csp_nonce: String,
19397    server_mode: bool,
19398}
19399
19400// ── CompareTemplate ────────────────────────────────────────────────────────────
19401
19402#[derive(Template)]
19403#[template(
19404    source = r##"
19405<!doctype html>
19406<html lang="en">
19407<head>
19408  <meta charset="utf-8">
19409  <meta name="viewport" content="width=device-width, initial-scale=1">
19410  <title>OxideSLOC | Scan Delta</title>
19411  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19412  <style nonce="{{ csp_nonce }}">
19413    :root {
19414      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
19415      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
19416      --nav:#283790; --nav-2:#013e6b;
19417      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
19418      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
19419      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
19420    }
19421    body.dark-theme {
19422      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
19423      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
19424    }
19425    *{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;}
19426    .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);}
19427    .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;}
19428    .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));}
19429    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19430    .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;}
19431    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
19432    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19433    @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; } }
19434    .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;}
19435    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19436    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19437    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19438    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19439    .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;}
19440    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19441    .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);}
19442    .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;}
19443    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19444    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19445    .settings-modal-body{padding:14px 16px 16px;}
19446    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19447    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19448    .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;}
19449    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19450    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19451    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19452    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19453    .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;}
19454    .tz-select:focus{border-color:var(--oxide);}
19455    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
19456    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
19457    .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;}
19458    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
19459    .hero-body{display:block;}
19460    .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;}
19461    .btn-back:hover{background:var(--line);}
19462    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
19463    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
19464    .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;}
19465    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
19466    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;}
19467    .muted{color:var(--muted);font-size:14px;}
19468    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
19469    .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;}
19470    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
19471    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
19472    .vpill-arrow{font-size:20px;color:var(--muted);}
19473    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
19474    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
19475    .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;}
19476    .delta-card.delta-card-wide{padding:22px 24px;}
19477    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
19478    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
19479    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
19480    .delta-card-from{font-size:15px;color:var(--muted);}
19481    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
19482    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
19483    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
19484    .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%;}
19485    .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;}
19486    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
19487    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
19488    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
19489    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
19490    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
19491    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
19492    .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;}
19493    .meta-card-commit:hover{color:var(--oxide);}
19494    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
19495    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
19496    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
19497    .meta-value{color:var(--text);font-size:13px;}
19498    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
19499    .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;}
19500    .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);}
19501    .delta-card:hover .dc-tip{display:block;}
19502    .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;}
19503    .export-btn:hover{background:var(--line);}
19504    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
19505    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
19506    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
19507    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
19508    .delta-card-change.zero{color:var(--muted);background:transparent;}
19509    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
19510    .delta-card-pct.pos{color:var(--pos);}
19511    .delta-card-pct.neg{color:var(--neg);}
19512    .delta-card-pct.zero{color:var(--muted);}
19513    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
19514    .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;}
19515    .insight-card.insight-flag{border-color:var(--oxide);}
19516    .insight-card:hover .dc-tip{display:block;}
19517    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
19518    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
19519    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
19520    .insight-label.flag{color:var(--oxide);}
19521    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
19522    .insight-val.pos{color:var(--pos);}
19523    .insight-val.neg{color:var(--neg);}
19524    .insight-val.high{color:#c0392a;}
19525    .insight-val.med{color:#926000;}
19526    .insight-val.low{color:var(--pos);}
19527    body.dark-theme .insight-val.high{color:#ff6b6b;}
19528    body.dark-theme .insight-val.med{color:#f0c060;}
19529    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
19530    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
19531    .fc-row{display:flex;align-items:center;gap:8px;}
19532    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
19533    .fc-label{color:var(--muted);}
19534    .fc-modified .fc-count{color:#926000;}
19535    .fc-added .fc-count{color:var(--pos);}
19536    .fc-removed .fc-count{color:var(--neg);}
19537    .fc-unchanged .fc-count{color:var(--muted);}
19538    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
19539    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
19540    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
19541    .chip.modified{background:#fff2d8;color:#926000;}
19542    .chip.added{background:#e8f5ed;color:#1a8f47;}
19543    .chip.removed{background:#fdeaea;color:#b33b3b;}
19544    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
19545    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
19546    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
19547    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
19548    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
19549    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
19550    .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;}
19551    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
19552    .tab-btn:hover:not(.active){background:var(--line);}
19553    .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;}
19554    .btn-reset:hover{background:var(--line);}
19555    .table-wrap{width:100%;overflow-x:auto;}
19556    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
19557    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;}
19558    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
19559    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
19560    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
19561    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
19562    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
19563    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19564    tr:last-child td{border-bottom:none;}
19565    tr.row-added td{background:rgba(26,143,71,0.06);}
19566    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
19567    tr.row-modified td{background:rgba(146,96,0,0.05);}
19568    tr.row-unchanged td{opacity:.6;}
19569    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
19570    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
19571    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
19572    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
19573    .status-badge.modified{background:#fff2d8;color:#926000;}
19574    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
19575    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
19576    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
19577    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
19578    .delta-val{font-weight:700;}
19579    .delta-val.pos{color:var(--pos);}
19580    .delta-val.neg{color:var(--neg);}
19581    .delta-val.zero{color:var(--muted);}
19582    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
19583    .from-to strong{color:var(--text);}
19584    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19585    .site-footer a{color:var(--muted);}
19586    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
19587    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
19588    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19589    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19590    .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;}
19591    .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;}
19592    .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;}
19593    @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));}}
19594    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
19595    .path-link:hover{color:var(--oxide-2);}
19596    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
19597    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
19598    a.vpill-id:hover{color:var(--oxide);}
19599    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
19600    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
19601    .pagination-info{font-size:13px;color:var(--muted);}
19602    .pagination-btns{display:flex;gap:6px;}
19603    .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;}
19604    .pg-btn:hover:not(:disabled){background:var(--line);}
19605    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19606    .pg-btn:disabled{opacity:.35;cursor:default;}
19607    .per-page-label{font-size:13px;color:var(--muted);}
19608    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;}
19609    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19610    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
19611    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
19612    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
19613    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
19614    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
19615    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
19616    .tab-btn.tab-unchanged{color:var(--muted);}
19617    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
19618    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
19619    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
19620    .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;}
19621    .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;}
19622    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
19623    .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;}
19624    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
19625    .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;}
19626    .submod-scope-btn:hover{background:var(--line);}
19627    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19628    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
19629    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
19630    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
19631    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
19632    body.dark-theme .ic-card{background:var(--surface-2);}
19633    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
19634    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
19635    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
19636    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
19637    #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;}
19638  </style>
19639</head>
19640<body>
19641  <div class="background-watermarks" aria-hidden="true">
19642    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19643    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19644    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19645    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19646    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19647    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19648  </div>
19649  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19650  <div class="top-nav">
19651    <div class="top-nav-inner">
19652      <a class="brand" href="/">
19653        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19654        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
19655      </a>
19656      <div class="nav-right">
19657        <a class="nav-pill" href="/">Home</a>
19658        <div class="nav-dropdown">
19659          <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>
19660          <div class="nav-dropdown-menu">
19661            <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>
19662          </div>
19663        </div>
19664        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19665        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19666        <div class="nav-dropdown">
19667          <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>
19668          <div class="nav-dropdown-menu">
19669            <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>
19670          </div>
19671        </div>
19672        <div class="server-status-wrap" id="server-status-wrap">
19673          <div class="nav-pill server-online-pill" id="server-status-pill">
19674            <span class="status-dot" id="status-dot"></span>
19675            <span id="server-status-label">Server</span>
19676            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19677          </div>
19678          <div class="server-status-tip">
19679            OxideSLOC is running — accessible on your network.
19680            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19681          </div>
19682        </div>
19683        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19684          <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>
19685        </button>
19686        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19687          <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>
19688          <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>
19689        </button>
19690      </div>
19691    </div>
19692  </div>
19693
19694  <div class="page">
19695    <section class="hero">
19696      <div class="hero-header">
19697        <div>
19698          <h1 class="delta-title">Scan Delta</h1>
19699          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
19700          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
19701            {% if let Some(sub) = active_submodule %}
19702            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
19703            {% else if super_scope_active %}
19704            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
19705            {% else %}
19706            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
19707            {% endif %}
19708            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
19709          </div>
19710        </div>
19711        <a class="btn-back" href="/compare-scans">
19712          <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>
19713          Compare Scans
19714        </a>
19715      </div>
19716      {% if has_any_submodule_data %}
19717      <div class="submod-scope-bar">
19718        <span class="submod-scope-label">
19719          <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>
19720          Scope:
19721        </span>
19722        <div class="submod-scope-divider"></div>
19723        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
19724           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
19725           title="All files — super-repo and all submodules combined">Full scan</a>
19726        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
19727           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
19728           title="Only files that are not part of any submodule">Super-repo only</a>
19729        {% for sub in submodule_options %}
19730        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
19731           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
19732           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
19733        {% endfor %}
19734      </div>
19735      {% endif %}
19736      <div class="hero-body">
19737      <div class="meta-strip">
19738        <div class="delta-card delta-card-meta">
19739          <div class="meta-card-header">
19740            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
19741            <div class="meta-card-project-col">
19742              <div class="meta-card-project">{{ project_name }}</div>
19743              {% if has_any_submodule_data %}
19744              {% if let Some(sub) = active_submodule %}
19745              <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>
19746              {% else if super_scope_active %}
19747              <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>
19748              {% else %}
19749              <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>
19750              {% endif %}
19751              {% endif %}
19752            </div>
19753          </div>
19754          {% if !baseline_git_commit.is_empty() %}
19755          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
19756          {% else %}
19757          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
19758          {% endif %}
19759          <div class="meta-card-rows">
19760            <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>
19761            <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>
19762            <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>
19763            <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>
19764            {% if let Some(tags) = baseline_git_tags %}
19765            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
19766            {% endif %}
19767          </div>
19768        </div>
19769        <div class="delta-card delta-card-meta">
19770          <div class="meta-card-header">
19771            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
19772            <div class="meta-card-project-col">
19773              <div class="meta-card-project">{{ project_name }}</div>
19774              {% if has_any_submodule_data %}
19775              {% if let Some(sub) = active_submodule %}
19776              <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>
19777              {% else if super_scope_active %}
19778              <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>
19779              {% else %}
19780              <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>
19781              {% endif %}
19782              {% endif %}
19783            </div>
19784          </div>
19785          {% if !current_git_commit.is_empty() %}
19786          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
19787          {% else %}
19788          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
19789          {% endif %}
19790          <div class="meta-card-rows">
19791            <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>
19792            <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>
19793            <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>
19794            <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>
19795            {% if let Some(tags) = current_git_tags %}
19796            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
19797            {% endif %}
19798          </div>
19799        </div>
19800      </div>
19801      <div class="delta-strip">
19802        <div class="delta-card">
19803          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
19804          <div class="delta-card-label">Code lines</div>
19805          <div class="delta-card-from">Before: {{ baseline_code }}</div>
19806          <div class="delta-card-to">{{ current_code }}</div>
19807          {% 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>
19808          {% 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>
19809          {% else %}<div class="delta-card-pct zero">±0%</div>
19810          {% endif %}
19811        </div>
19812        <div class="delta-card">
19813          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
19814          <div class="delta-card-label">Files analyzed</div>
19815          <div class="delta-card-from">Before: {{ baseline_files }}</div>
19816          <div class="delta-card-to">{{ current_files }}</div>
19817          {% 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>
19818          {% 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>
19819          {% else %}<div class="delta-card-pct zero">±0%</div>
19820          {% endif %}
19821        </div>
19822        <div class="delta-card">
19823          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
19824          <div class="delta-card-label">Comment lines</div>
19825          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
19826          <div class="delta-card-to">{{ current_comments }}</div>
19827          {% 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>
19828          {% 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>
19829          {% else %}<div class="delta-card-pct zero">±0%</div>
19830          {% endif %}
19831        </div>
19832        {{ coverage_delta_card|safe }}
19833        <div class="delta-card delta-card-wide">
19834          <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>
19835          <div class="delta-card-label">File changes</div>
19836          <div class="file-changes-grid">
19837            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
19838            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
19839            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
19840            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
19841          </div>
19842        </div>
19843      </div>
19844      <div class="insights-panel">
19845        <div class="insight-card">
19846          <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>
19847          <div class="insight-label">Lines Added</div>
19848          <div class="insight-val pos">+{{ code_lines_added }}</div>
19849          <div class="insight-sub">New or grown source lines</div>
19850        </div>
19851        <div class="insight-card">
19852          <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>
19853          <div class="insight-label">Lines Removed</div>
19854          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
19855          <div class="insight-sub">Deleted or shrunk source lines</div>
19856        </div>
19857        <div class="insight-card">
19858          <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>
19859          <div class="insight-label">Churn Rate</div>
19860          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
19861          <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>
19862        </div>
19863        {% if scope_flag %}
19864        <div class="insight-card insight-flag">
19865          <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>
19866          <div class="insight-label flag">Scope Signal</div>
19867          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
19868          <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>
19869        </div>
19870        {% endif %}
19871      </div>
19872      </div>
19873    </section>
19874
19875    <section class="panel" id="inline-charts-section">
19876      <h2>Scan Delta Charts</h2>
19877      <div class="ic-grid">
19878        <div class="ic-card">
19879          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
19880          <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>
19881          <div id="ic-c1"></div>
19882        </div>
19883        <div class="ic-card" id="ic-lang-card">
19884          <div class="ic-card-h2">Language Code Delta</div>
19885          <div id="ic-c3"></div>
19886        </div>
19887        <div class="ic-card">
19888          <div class="ic-card-h2">Delta by Metric</div>
19889          <div id="ic-c2"></div>
19890        </div>
19891        <div class="ic-card">
19892          <div class="ic-card-h2">File Change Distribution</div>
19893          <div id="ic-c4"></div>
19894        </div>
19895      </div>
19896    </section>
19897
19898    <section class="panel">
19899      <h2>File-level delta</h2>
19900      <div class="filter-tabs-row">
19901        <div class="filter-tabs">
19902          <button class="tab-btn tab-all active" data-filter="all">All</button>
19903          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
19904          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
19905          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
19906          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
19907        </div>
19908        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
19909          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
19910          <div class="export-group">
19911            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
19912            <button type="button" class="export-btn" id="delta-csv-btn">
19913              <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>
19914              CSV
19915            </button>
19916            <button type="button" class="export-btn" id="delta-xls-btn">
19917              <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>
19918              Excel
19919            </button>
19920            <button type="button" class="export-btn" id="delta-charts-btn">
19921              <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>
19922              Charts
19923            </button>
19924          </div>
19925        </div>
19926      </div>
19927
19928      <div class="table-wrap">
19929      <table id="delta-table">
19930        <colgroup>
19931          <col>
19932          <col>
19933          <col>
19934          <col>
19935          <col>
19936          <col>
19937          <col>
19938        </colgroup>
19939        <thead>
19940          <tr id="delta-thead">
19941            <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>
19942            <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>
19943            <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>
19944            <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>
19945            <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>
19946            <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>
19947            <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>
19948          </tr>
19949        </thead>
19950        <tbody id="delta-tbody">
19951          {% for row in file_rows %}
19952          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
19953              data-path="{{ row.relative_path }}"
19954              data-language="{{ row.language }}"
19955              data-baseline-code="{{ row.baseline_code }}"
19956              data-current-code="{{ row.current_code }}"
19957              data-code-delta="{{ row.code_delta_str }}"
19958              data-comment-delta="{{ row.comment_delta_str }}"
19959              data-total-delta="{{ row.total_delta_str }}"
19960              data-orig-idx="">
19961            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
19962            <td class="hide-sm">{{ row.language }}</td>
19963            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
19964            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
19965            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
19966            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
19967            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
19968          </tr>
19969          {% endfor %}
19970        </tbody>
19971      </table>
19972      </div>
19973      <div class="pagination">
19974        <span class="pagination-info" id="pg-info"></span>
19975        <div class="pagination-btns" id="pg-btns"></div>
19976        <div class="flex-row">
19977          <span class="per-page-label">Show</span>
19978          <select class="per-page" id="per-page-sel">
19979            <option value="10">10 per page</option>
19980            <option value="25" selected>25 per page</option>
19981            <option value="50">50 per page</option>
19982            <option value="100">100 per page</option>
19983          </select>
19984          <span class="per-page-label" id="pg-range-label"></span>
19985        </div>
19986      </div>
19987    </section>
19988  </div>
19989
19990  <div id="ic-tt"></div>
19991
19992  <footer class="site-footer">
19993    local code analysis - metrics, history and reports
19994    &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>
19995    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19996    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19997    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19998    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19999  </footer>
20000
20001  <script nonce="{{ csp_nonce }}">
20002    (function () {
20003      var storageKey = 'oxide-sloc-theme';
20004      var body = document.body;
20005      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20006      var toggle = document.getElementById('theme-toggle');
20007      if (toggle) toggle.addEventListener('click', function () {
20008        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20009        body.classList.toggle('dark-theme', next === 'dark');
20010        try { localStorage.setItem(storageKey, next); } catch(e) {}
20011      });
20012
20013      (function randomizeWatermarks() {
20014        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20015        if (!wms.length) return;
20016        var placed = [];
20017        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;}
20018        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];}
20019        var half=Math.floor(wms.length/2);
20020        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;});
20021      })();
20022
20023      (function spawnCodeParticles() {
20024        var container = document.getElementById('code-particles');
20025        if (!container) return;
20026        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'];
20027        for (var i = 0; i < 38; i++) {
20028          (function(idx) {
20029            var el = document.createElement('span');
20030            el.className = 'code-particle';
20031            el.textContent = snippets[idx % snippets.length];
20032            var left = Math.random() * 94 + 2;
20033            var top = Math.random() * 88 + 6;
20034            var dur = (Math.random() * 10 + 9).toFixed(1);
20035            var delay = (Math.random() * 18).toFixed(1);
20036            var rot = (Math.random() * 26 - 13).toFixed(1);
20037            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20038            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';
20039            container.appendChild(el);
20040          })(i);
20041        }
20042      })();
20043    })();
20044
20045    var activeStatusFilter = 'all';
20046    var deltaPerPage = 25, deltaCurrPage = 1;
20047
20048    function openFolder(path) {
20049      fetch('/open-path?path=' + encodeURIComponent(path))
20050        .then(function (r) { return r.json(); })
20051        .then(function (d) {
20052          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20053        })
20054        .catch(function () {});
20055    }
20056
20057    function getDeltaFilteredRows() {
20058      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
20059        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
20060      });
20061    }
20062
20063    function renderDeltaPage() {
20064      var filtered = getDeltaFilteredRows();
20065      var total = filtered.length;
20066      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
20067      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
20068      var start = (deltaCurrPage - 1) * deltaPerPage;
20069      var end = Math.min(start + deltaPerPage, total);
20070      var shownSet = {};
20071      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
20072      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
20073        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
20074      });
20075      var rl = document.getElementById('pg-range-label');
20076      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
20077      var info = document.getElementById('pg-info');
20078      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
20079      var btns = document.getElementById('pg-btns');
20080      if (!btns) return;
20081      btns.innerHTML = '';
20082      if (totalPages <= 1) return;
20083      function makeBtn(lbl, pg, active, disabled) {
20084        var b = document.createElement('button');
20085        b.className = 'pg-btn' + (active ? ' active' : '');
20086        b.textContent = lbl; b.disabled = disabled;
20087        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
20088        return b;
20089      }
20090      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
20091      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
20092      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
20093      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
20094    }
20095
20096    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
20097
20098    function filterRows(status, btn) {
20099      activeStatusFilter = status;
20100      deltaCurrPage = 1;
20101      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
20102        b.classList.remove('active');
20103      });
20104      if (btn) btn.classList.add('active');
20105      renderDeltaPage();
20106    }
20107
20108    // ── Sorting ──────────────────────────────────────────────────────────────
20109    var sortCol = null, sortOrder = 'asc';
20110    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
20111    (function() {
20112      var tbody = document.getElementById('delta-tbody');
20113      if (!tbody) return;
20114      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20115      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
20116    })();
20117
20118    function parseDeltaNum(str) {
20119      if (!str || str === '—') return 0;
20120      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
20121    }
20122
20123    sortHeaders.forEach(function(th) {
20124      th.addEventListener('click', function(e) {
20125        if (e.target.classList.contains('col-resize-handle')) return;
20126        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
20127        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
20128        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20129        th.classList.add('sort-' + sortOrder);
20130        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
20131        var tbody = document.getElementById('delta-tbody');
20132        if (!tbody) return;
20133        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20134        rows.sort(function(a, b) {
20135          var va, vb;
20136          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
20137          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
20138          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
20139          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
20140          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20141          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20142          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20143          else { va = ''; vb = ''; }
20144          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
20145          return va < vb ? 1 : va > vb ? -1 : 0;
20146        });
20147        rows.forEach(function(r) { tbody.appendChild(r); });
20148        deltaCurrPage = 1;
20149        renderDeltaPage();
20150        var activeBtn = document.querySelector('.tab-btn.active');
20151        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20152        if (activeBtn) activeBtn.classList.add('active');
20153      });
20154    });
20155
20156    // ── Column resize ─────────────────────────────────────────────────────────
20157    (function() {
20158      var table = document.getElementById('delta-table');
20159      if (!table) return;
20160      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
20161      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
20162      ths.forEach(function(th, i) {
20163        var handle = th.querySelector('.col-resize-handle');
20164        if (!handle || !cols[i]) return;
20165        var startX, startW;
20166        handle.addEventListener('mousedown', function(e) {
20167          e.stopPropagation(); e.preventDefault();
20168          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
20169          handle.classList.add('dragging');
20170          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
20171          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
20172          document.addEventListener('mousemove', onMove);
20173          document.addEventListener('mouseup', onUp);
20174        });
20175      });
20176    })();
20177
20178    // ── Reset ─────────────────────────────────────────────────────────────────
20179    window.resetDeltaTable = function() {
20180      sortCol = null; sortOrder = 'asc';
20181      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20182      var tbody = document.getElementById('delta-tbody');
20183      if (tbody) {
20184        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20185        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
20186        rows.forEach(function(r) { tbody.appendChild(r); });
20187      }
20188      var table = document.getElementById('delta-table');
20189      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
20190      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
20191      activeStatusFilter = 'all';
20192      deltaCurrPage = 1;
20193      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20194      var allBtn = document.querySelector('.tab-btn');
20195      if (allBtn) allBtn.classList.add('active');
20196      renderDeltaPage();
20197    };
20198
20199    renderDeltaPage();
20200
20201    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
20202    (function() {
20203      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
20204        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
20205      });
20206      var resetBtn = document.getElementById('delta-reset-btn');
20207      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
20208      var csvBtn = document.getElementById('delta-csv-btn');
20209      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
20210      var xlsBtn = document.getElementById('delta-xls-btn');
20211      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
20212      var chartsBtn = document.getElementById('delta-charts-btn');
20213      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
20214      var ppSel = document.getElementById('per-page-sel');
20215      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
20216      var pathLink = document.getElementById('project-path-link');
20217      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
20218    })();
20219
20220    // ── Export helpers ────────────────────────────────────────────────────────
20221    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
20222    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
20223    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);}
20224    function slocMakeXlsx(fname,sd,dr){
20225      var enc=new TextEncoder();
20226      // CRC-32 table
20227      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;}
20228      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;}
20229      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
20230      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
20231      // Shared string table
20232      var ss=[],si={};
20233      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
20234      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20235      // Worksheet builder — each WS() call gets its own row counter R
20236      function WS(){
20237        var R=0,buf=[];
20238        function cl(c){return String.fromCharCode(65+c);}
20239        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
20240          '<v>'+S(v)+'</v></c>';}
20241        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
20242          (st?' s="'+st+'"':'')+'>'+
20243          '<v>'+(+v)+'</v></c>';}
20244        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
20245        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20246          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
20247          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
20248          '<sheetFormatPr defaultRowHeight="15"/>'+
20249          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
20250        return{sc:sc,nc:nc,row:row,xml:xml};
20251      }
20252      // Language breakdown
20253      var lm={};
20254      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;});
20255      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
20256      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
20257      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
20258      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
20259      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20260      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20261      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):'';}
20262      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
20263      // Summary sheet
20264      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
20265      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
20266      r1(s1(0,proj,2));
20267      r1(s1(0,sd.bts+' → '+sd.cts,2));
20268      r1('');
20269      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
20270      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))));
20271      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))));
20272      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))));
20273      r1('');
20274      r1(s1(0,'FILE CHANGES',8));
20275      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
20276      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
20277      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
20278      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
20279      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
20280      if(langs.length){
20281        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
20282        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
20283        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)));});
20284      }
20285      r1('');r1(s1(0,'SCAN METADATA',8));
20286      r1(s1(1,_blabel)+s1(2,_clabel));
20287      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
20288      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
20289      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"/>');
20290      // File Delta sheet
20291      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
20292      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));
20293      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)));});
20294      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
20295      // Shared strings XML
20296      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20297        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
20298        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
20299      // XLSX file map
20300      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
20301      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>',
20302        '_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>',
20303        '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>',
20304        '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>',
20305        '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>',
20306        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
20307      // ZIP packer — STORED (no compression), compatible with all XLSX readers
20308      var zparts=[],zcds=[],zoff=0,znf=0;
20309      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
20310       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
20311      ].forEach(function(name){
20312        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
20313        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]);
20314        var entry=new Uint8Array(lha.length+nb.length+sz);
20315        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
20316        zparts.push(entry);
20317        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));
20318        var cde=new Uint8Array(cda.length+nb.length);
20319        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
20320        zcds.push(cde);zoff+=entry.length;znf++;
20321      });
20322      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
20323      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]);
20324      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
20325      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
20326      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
20327      zout.set(new Uint8Array(ea),zpos);
20328      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
20329      var xurl=URL.createObjectURL(xblob);
20330      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
20331      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
20332      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
20333    }
20334    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;');}
20335    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
20336    function getExportFilename(ext){return _exportBase+'.'+ext;}
20337
20338    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 }}'};
20339    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;}
20340    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
20341    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
20342    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20343    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20344    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):'';}
20345    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
20346    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)]];}
20347    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
20348    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;}
20349    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
20350    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
20351
20352    // ── Chart HTML report ─────────────────────────────────────────────────────
20353    function slocChartReport(fname, sd, dr) {
20354      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
20355      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20356      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20357      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();}
20358      function px(n){return Math.round(n);}
20359      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
20360      // Language map
20361      var lm={};
20362      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;});
20363      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20364
20365      // Builds onmouse* attrs for interactive tooltip on each SVG element
20366      function barTT(label,val){
20367        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
20368      }
20369
20370      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
20371      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'}];
20372      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
20373      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
20374      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
20375      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20376      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"/>';}
20377      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
20378      c1mets.forEach(function(m,i){
20379        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
20380        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
20381        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>';
20382        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))+'/>';
20383        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>';
20384        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))+'/>';
20385        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>';
20386        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>';
20387        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>';
20388      });
20389      c1+='</svg>';
20390
20391      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
20392      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'}];
20393      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
20394      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
20395      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
20396      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20397      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20398      mets.forEach(function(m,i){
20399        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
20400        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
20401        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
20402        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>';
20403        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
20404        if(bw>=52){
20405          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>';
20406        }else{
20407          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
20408          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>';
20409        }
20410      });
20411      c2+='</svg>';
20412
20413      // ── Chart 3: Language Code Delta ─────────────────────────────────────
20414      var c3='';
20415      if(langs.length){
20416        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
20417        var C3W=550,c3LW=124,c3FW=52;
20418        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
20419        var L3rH=30,C3H=langs.length*L3rH+20;
20420        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20421        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20422        langs.forEach(function(l,i){
20423          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
20424          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
20425          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
20426          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
20427          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':''))+'/>';
20428          if(bw>=48){
20429            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>';
20430          }else{
20431            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
20432            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>';
20433          }
20434          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>';
20435        });
20436        c3+='</svg>';
20437      }
20438
20439      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
20440      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;});
20441      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
20442      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
20443      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20444      var ang=-Math.PI/2;
20445      segs.forEach(function(s){
20446        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
20447        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
20448        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
20449        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
20450        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
20451        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)+'%')+'/>';
20452        ang+=sw;
20453      });
20454      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>';
20455      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
20456      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>';});
20457      c4+='</svg>';
20458
20459      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
20460      var ttJs='var tt=document.getElementById("ox-tt");'+
20461        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
20462        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
20463        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
20464        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
20465        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
20466        'function oxHT(){tt.style.display="none";}';
20467
20468      // body max-width keeps charts from inflating beyond design dimensions on
20469      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
20470      // each chart's height blows up proportionally, breaking the one-page layout.
20471      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;}'+
20472        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
20473        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
20474        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
20475        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
20476        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
20477        'svg{display:block;}'+
20478        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
20479        '#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;}'+
20480        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
20481      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
20482        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
20483        '<div id="ox-tt"><\/div>'+
20484        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
20485        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
20486        '<div class="two-col">'+
20487        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
20488        '<div class="leg">'+
20489        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
20490        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
20491        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
20492        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
20493        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
20494        '<\/div>'+
20495        '<div class="two-col">'+
20496        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
20497        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
20498        '<\/div>'+
20499        '<script>'+ttJs+'<\/script>'+
20500        '<\/body><\/html>';
20501      slocDownload(html, fname, 'text/html;charset=utf-8;');
20502    }
20503    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
20504    // ── Inline delta charts ────────────────────────────────────────────────────
20505    var _icTT=document.getElementById('ic-tt');
20506    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
20507    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';};
20508    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
20509    (function(){
20510      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
20511      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20512      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();}
20513      function px(n){return Math.round(n);}
20514      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20515      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
20516      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);});}
20517      var dr=getDeltaExportRows(),sd=_sd,lm={};
20518      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;});
20519      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20520      // Chart 1: Baseline vs Current grouped bars
20521      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'}];
20522      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
20523      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;
20524      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20525      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"/>';}
20526      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
20527      c1mets.forEach(function(m,i){
20528        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
20529        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
20530        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>';
20531        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"/>';
20532        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>';
20533        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"/>';
20534        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>';
20535        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>';
20536        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>';
20537      });
20538      c1+='</svg>';
20539      // Chart 2: Delta by Metric
20540      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'}];
20541      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
20542      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;
20543      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20544      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20545      mets.forEach(function(m,i){
20546        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);
20547        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>';
20548        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
20549        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>';}
20550        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>';}
20551      });
20552      c2+='</svg>';
20553      // Chart 3: Language Code Delta
20554      var c3='';
20555      if(langs.length){
20556        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
20557        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;
20558        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20559        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20560        langs.forEach(function(l,i){
20561          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);
20562          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
20563          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"/>';
20564          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>';}
20565          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>';}
20566          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>';
20567        });
20568        c3+='</svg>';
20569      }
20570      // Chart 4: File Change Donut
20571      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;});
20572      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
20573      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;
20574      if(segs.length===1){
20575        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
20576        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
20577        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
20578      } else {
20579        segs.forEach(function(s){
20580          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
20581          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);
20582          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);
20583          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"/>';
20584          ang+=sw;
20585        });
20586      }
20587      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>';
20588      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
20589      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>';});
20590      c4+='</svg>';
20591      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
20592      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
20593      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);}
20594      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
20595      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
20596      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='  /'+el.textContent.replace(/\s+/g,'');});
20597    })();
20598  </script>
20599  <script nonce="{{ csp_nonce }}">
20600  (function(){
20601    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'}];
20602    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);});}
20603    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20604    function init(){
20605      var btn=document.getElementById('settings-btn');if(!btn)return;
20606      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20607      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>';
20608      document.body.appendChild(m);
20609      var g=document.getElementById('scheme-grid');
20610      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);});
20611      var cl=document.getElementById('settings-close');
20612      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);
20613      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');});
20614      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20615      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20616    }
20617    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20618  }());
20619  </script>
20620  <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>
20621</body>
20622</html>
20623"##,
20624    ext = "html"
20625)]
20626// Template structs need many bool fields to pass Askama rendering flags.
20627#[allow(clippy::struct_excessive_bools)]
20628struct CompareTemplate {
20629    version: &'static str,
20630    project_label: String,
20631    baseline_git_commit: String,
20632    current_git_commit: String,
20633    baseline_run_id: String,
20634    current_run_id: String,
20635    baseline_run_id_short: String,
20636    current_run_id_short: String,
20637    baseline_timestamp: String,
20638    baseline_timestamp_utc_ms: i64,
20639    current_timestamp: String,
20640    current_timestamp_utc_ms: i64,
20641    project_path: String,
20642    baseline_code: u64,
20643    current_code: u64,
20644    code_lines_delta_str: String,
20645    code_lines_delta_class: String,
20646    baseline_files: u64,
20647    current_files: u64,
20648    files_analyzed_delta_str: String,
20649    files_analyzed_delta_class: String,
20650    baseline_comments: u64,
20651    current_comments: u64,
20652    comment_lines_delta_str: String,
20653    comment_lines_delta_class: String,
20654    code_lines_pct_str: String,
20655    files_analyzed_pct_str: String,
20656    comment_lines_pct_str: String,
20657    code_lines_added: i64,
20658    code_lines_removed: i64,
20659    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
20660    new_scope: bool,
20661    churn_rate_str: String,
20662    churn_rate_class: String,
20663    scope_flag: bool,
20664    files_added: usize,
20665    files_removed: usize,
20666    files_modified: usize,
20667    files_unchanged: usize,
20668    file_rows: Vec<CompareFileDeltaRow>,
20669    baseline_git_author: Option<String>,
20670    current_git_author: Option<String>,
20671    baseline_git_branch: String,
20672    current_git_branch: String,
20673    baseline_git_tags: Option<String>,
20674    current_git_tags: Option<String>,
20675    baseline_git_commit_date: Option<String>,
20676    current_git_commit_date: Option<String>,
20677    project_name: String,
20678    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
20679    submodule_options: Vec<String>,
20680    /// True when either run has submodule data — controls whether the scope bar is shown.
20681    has_any_submodule_data: bool,
20682    /// The submodule currently being compared, if the `sub` query param was provided.
20683    active_submodule: Option<String>,
20684    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
20685    super_scope_active: bool,
20686    csp_nonce: String,
20687    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
20688    coverage_delta_card: String,
20689}
20690
20691// ── LoginTemplate ──────────────────────────────────────────────────────────────
20692
20693#[derive(Template)]
20694#[template(
20695    source = r##"
20696<!doctype html>
20697<html lang="en">
20698<head>
20699  <meta charset="utf-8">
20700  <meta name="viewport" content="width=device-width, initial-scale=1">
20701  <title>OxideSLOC | Sign In</title>
20702  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20703  <style nonce="{{ csp_nonce }}">
20704    :root {
20705      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
20706      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
20707      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
20708      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
20709    }
20710    *{box-sizing:border-box;}
20711    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);}
20712    .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);}
20713    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
20714    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
20715    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
20716    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20717    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20718    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20719    .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;}
20720    @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));}}
20721    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
20722    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
20723    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
20724    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
20725    .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;}
20726    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
20727    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;}
20728    input[type=password]:focus{border-color:var(--oxide);}
20729    .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;}
20730    .btn:hover{opacity:.88;}
20731    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
20732    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
20733  </style>
20734</head>
20735<body>
20736  <div class="background-watermarks" aria-hidden="true">
20737    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20738    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20739    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20740    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20741    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20742    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20743    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20744  </div>
20745  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20746<nav class="top-nav">
20747  <a class="brand" href="/">
20748    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
20749    <span class="brand-title">OxideSLOC</span>
20750  </a>
20751</nav>
20752<main class="page">
20753  <div class="card">
20754    <h1>Sign In</h1>
20755    <p class="subtitle">Enter the API key printed when the server started.</p>
20756    {% if has_error %}
20757    <div class="error">Incorrect API key — please try again.</div>
20758    {% endif %}
20759    <form method="POST" action="/auth/login">
20760      <input type="hidden" name="next" value="{{ next_url|e }}">
20761      <label for="key">API Key</label>
20762      <input id="key" type="password" name="key" autocomplete="current-password"
20763             placeholder="Paste your API key here" autofocus>
20764      <button type="submit" class="btn">Sign In</button>
20765    </form>
20766    <p class="hint">
20767      The API key was printed in the terminal when the server started.<br>
20768      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
20769      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
20770    </p>
20771  </div>
20772</main>
20773<script nonce="{{ csp_nonce }}">
20774(function() {
20775  (function randomizeWatermarks() {
20776    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20777    if (!wms.length) return;
20778    var placed = [];
20779    function tooClose(top, left) {
20780      for (var i = 0; i < placed.length; i++) {
20781        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
20782        if (dt < 16 && dl < 12) return true;
20783      }
20784      return false;
20785    }
20786    function pick(leftBand) {
20787      for (var attempt = 0; attempt < 50; attempt++) {
20788        var top = Math.random() * 88 + 2;
20789        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20790        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
20791      }
20792      var top = Math.random() * 88 + 2;
20793      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20794      placed.push([top, left]); return [top, left];
20795    }
20796    var half = Math.floor(wms.length / 2);
20797    wms.forEach(function (img, i) {
20798      var pos = pick(i < half);
20799      var size = Math.floor(Math.random() * 100 + 120);
20800      var rot = (Math.random() * 360).toFixed(1);
20801      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
20802      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;
20803    });
20804  })();
20805  (function spawnCodeParticles() {
20806    var container = document.getElementById('code-particles');
20807    if (!container) return;
20808    var snippets = [
20809      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
20810      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
20811      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
20812      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
20813      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
20814    ];
20815    var count = 38;
20816    for (var i = 0; i < count; i++) {
20817      (function(idx) {
20818        var el = document.createElement('span');
20819        el.className = 'code-particle';
20820        el.textContent = snippets[idx % snippets.length];
20821        var left = Math.random() * 94 + 2;
20822        var top = Math.random() * 88 + 6;
20823        var dur = (Math.random() * 10 + 9).toFixed(1);
20824        var delay = (Math.random() * 18).toFixed(1);
20825        var rot = (Math.random() * 26 - 13).toFixed(1);
20826        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20827        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
20828        container.appendChild(el);
20829      })(i);
20830    }
20831  })();
20832})();
20833</script>
20834</body>
20835</html>
20836"##,
20837    ext = "html"
20838)]
20839pub(crate) struct LoginTemplate {
20840    pub(crate) csp_nonce: String,
20841    pub(crate) has_error: bool,
20842    pub(crate) next_url: String,
20843    pub(crate) lockout_threshold: u32,
20844}
20845
20846// ── REST API reference page ────────────────────────────────────────────────────
20847
20848#[derive(Template)]
20849#[template(
20850    source = r##"
20851<!doctype html>
20852<html lang="en">
20853<head>
20854  <meta charset="utf-8">
20855  <meta name="viewport" content="width=device-width, initial-scale=1">
20856  <title>OxideSLOC — REST API Reference</title>
20857  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20858  <style nonce="{{ csp_nonce }}">
20859    :root {
20860      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
20861      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20862      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
20863      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20864      --success:#16a34a;
20865    }
20866    body.dark-theme {
20867      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
20868      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
20869    }
20870    *{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;}
20871    .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);}
20872    .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;}
20873    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
20874    .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));}
20875    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
20876    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
20877    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
20878    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
20879    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20880    @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; } }
20881    .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;}
20882    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
20883    .nav-pill.active{background:rgba(255,255,255,0.22);}
20884    .nav-dropdown{position:relative;display:inline-flex;}
20885    .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;}
20886    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
20887    .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;}
20888    .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;}
20889    .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);}
20890    .nav-dropdown-menu a:last-child{border-bottom:none;}
20891    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
20892    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
20893    .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;}
20894    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20895    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20896    .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;}
20897    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20898    .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);}
20899    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
20900    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
20901    .settings-modal-body{padding:14px 16px 16px;}
20902    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20903    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20904    .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;}
20905    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20906    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20907    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20908    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20909    .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;}
20910    .tz-select:focus{border-color:var(--oxide);}
20911    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
20912    .page-header{margin-bottom:28px;}
20913    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
20914    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
20915    .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;}
20916    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
20917    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
20918    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
20919    .callout strong{font-weight:800;}
20920    .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;}
20921    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
20922    .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;}
20923    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
20924    .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;}
20925    body.dark-theme .base-url-value{color:var(--accent);}
20926    .section{margin-bottom:36px;}
20927    .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);}
20928    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
20929    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
20930    .ep-header:hover{background:var(--surface-2);}
20931    .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;}
20932    .method.get{background:#dcfce7;color:#166534;}
20933    .method.post{background:#dbeafe;color:#1e40af;}
20934    .method.delete{background:#fee2e2;color:#991b1b;}
20935    body.dark-theme .method.get{background:#14532d;color:#86efac;}
20936    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
20937    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
20938    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
20939    .ep-path .param{color:var(--oxide-2);}
20940    body.dark-theme .ep-path .param{color:var(--oxide);}
20941    .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;}
20942    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
20943    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
20944    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
20945    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
20946    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
20947    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
20948    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
20949    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
20950    .ep-card.open .chevron{transform:rotate(180deg);}
20951    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
20952    .ep-card.open .ep-body{display:block;}
20953    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
20954    .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;}
20955    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
20956    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
20957    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
20958    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
20959    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);}
20960    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
20961    table.params tr:last-child td{border-bottom:none;}
20962    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
20963    .pt-type{color:var(--muted-2);font-size:12px;}
20964    .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;}
20965    .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;}
20966    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
20967    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
20968    details.schema{margin-bottom:14px;}
20969    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;}
20970    details.schema summary:hover{color:var(--text);}
20971    .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;}
20972    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
20973    .curl-wrap{position:relative;}
20974    .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;}
20975    .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;}
20976    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
20977    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
20978    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
20979    .webhook-note a{color:var(--accent-2);text-decoration:none;}
20980    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20981    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20982    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20983    .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;}
20984    @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));}}
20985    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20986    .site-footer a{color:var(--muted);}
20987  </style>
20988</head>
20989<body>
20990  <div class="background-watermarks" aria-hidden="true">
20991    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20992    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20993    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20994    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20995    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20996    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20997    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20998  </div>
20999  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21000  <div class="top-nav">
21001    <div class="top-nav-inner">
21002      <a class="brand" href="/">
21003        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21004        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
21005      </a>
21006      <div class="nav-right">
21007        <a class="nav-pill" href="/">Home</a>
21008        <div class="nav-dropdown">
21009          <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>
21010          <div class="nav-dropdown-menu">
21011            <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>
21012          </div>
21013        </div>
21014        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21015        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21016        <div class="nav-dropdown">
21017          <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>
21018          <div class="nav-dropdown-menu">
21019            <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>
21020          </div>
21021        </div>
21022        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21023          <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>
21024        </button>
21025        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21026          <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>
21027          <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>
21028        </button>
21029      </div>
21030    </div>
21031  </div>
21032
21033  <div class="page">
21034    <div class="page-header">
21035      <h1 class="page-title">REST API Reference</h1>
21036      <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>
21037    </div>
21038
21039    {% if has_api_key %}
21040    <div class="callout key-set">
21041      <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>
21042      <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>
21043    </div>
21044    {% else %}
21045    <div class="callout no-key">
21046      <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>
21047      <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>
21048    </div>
21049    {% endif %}
21050
21051    <div class="base-url-bar">
21052      <span class="base-url-label">Base URL</span>
21053      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
21054    </div>
21055
21056    <!-- Health -->
21057    <div class="section">
21058      <h2 class="section-title">Health &amp; Status</h2>
21059      <div class="ep-card">
21060        <div class="ep-header">
21061          <span class="method get">GET</span>
21062          <span class="ep-path">/healthz</span>
21063          <span class="auth-badge public">Public</span>
21064          <span class="ep-desc">Server liveness check</span>
21065          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21066        </div>
21067        <div class="ep-body">
21068          <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>
21069          <p class="params-heading">Response</p>
21070          <div class="schema-block">200 OK
21071Content-Type: text/plain
21072
21073ok</div>
21074          <p class="curl-heading">Example</p>
21075          <div class="curl-wrap">
21076            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
21077            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
21078          </div>
21079        </div>
21080      </div>
21081    </div>
21082
21083    <!-- Badges -->
21084    <div class="section">
21085      <h2 class="section-title">Badges</h2>
21086      <div class="ep-card">
21087        <div class="ep-header">
21088          <span class="method get">GET</span>
21089          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
21090          <span class="auth-badge public">Public</span>
21091          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
21092          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21093        </div>
21094        <div class="ep-body">
21095          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
21096          <p class="params-heading">Path Parameters</p>
21097          <table class="params">
21098            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21099            <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>
21100          </table>
21101          <p class="curl-heading">Example</p>
21102          <div class="curl-wrap">
21103            <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>
21104            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
21105          </div>
21106        </div>
21107      </div>
21108    </div>
21109
21110    <!-- Metrics -->
21111    <div class="section">
21112      <h2 class="section-title">Metrics</h2>
21113
21114      <div class="ep-card">
21115        <div class="ep-header">
21116          <span class="method get">GET</span>
21117          <span class="ep-path">/api/metrics/latest</span>
21118          <span class="auth-badge protected">Protected</span>
21119          <span class="ep-desc">Latest scan metrics (JSON)</span>
21120          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21121        </div>
21122        <div class="ep-body">
21123          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
21124          <details class="schema"><summary>Response schema</summary>
21125<div class="schema-block">{
21126  "run_id":    string,        // UUID
21127  "timestamp": string,        // ISO-8601 UTC
21128  "project":   string,        // scanned root path
21129  "summary": {
21130    "files_analyzed":       number,
21131    "files_skipped":        number,
21132    "code_lines":           number,
21133    "comment_lines":        number,
21134    "blank_lines":          number,
21135    "total_physical_lines": number,
21136    "functions":            number,
21137    "classes":              number,
21138    "variables":            number,
21139    "imports":              number
21140  },
21141  "languages": [
21142    { "name": string, "files": number, "code_lines": number,
21143      "comment_lines": number, "blank_lines": number,
21144      "functions": number, "classes": number,
21145      "variables": number, "imports": number }
21146  ]
21147}</div></details>
21148          <p class="curl-heading">Example</p>
21149          <div class="curl-wrap">
21150            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21151  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
21152            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
21153          </div>
21154        </div>
21155      </div>
21156
21157      <div class="ep-card">
21158        <div class="ep-header">
21159          <span class="method get">GET</span>
21160          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
21161          <span class="auth-badge protected">Protected</span>
21162          <span class="ep-desc">Metrics for a specific run</span>
21163          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21164        </div>
21165        <div class="ep-body">
21166          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
21167          <p class="params-heading">Path Parameters</p>
21168          <table class="params">
21169            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21170            <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>
21171          </table>
21172          <p class="curl-heading">Example</p>
21173          <div class="curl-wrap">
21174            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21175  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
21176            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
21177          </div>
21178        </div>
21179      </div>
21180
21181      <div class="ep-card">
21182        <div class="ep-header">
21183          <span class="method get">GET</span>
21184          <span class="ep-path">/api/metrics/history</span>
21185          <span class="auth-badge protected">Protected</span>
21186          <span class="ep-desc">Paginated scan history</span>
21187          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21188        </div>
21189        <div class="ep-body">
21190          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
21191          <p class="params-heading">Query Parameters</p>
21192          <table class="params">
21193            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21194            <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>
21195            <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>
21196          </table>
21197          <details class="schema"><summary>Response schema</summary>
21198<div class="schema-block">[{
21199  "run_id":         string,
21200  "timestamp":      string,   // ISO-8601 UTC
21201  "commit":         string | null,
21202  "branch":         string | null,
21203  "tags":           string[],
21204  "code_lines":     number,
21205  "comment_lines":  number,
21206  "blank_lines":    number,
21207  "physical_lines": number,
21208  "files_analyzed": number,
21209  "project_label":  string,
21210  "html_url":       string | null
21211}]</div></details>
21212          <p class="curl-heading">Example</p>
21213          <div class="curl-wrap">
21214            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21215  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
21216            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
21217          </div>
21218        </div>
21219      </div>
21220
21221      <div class="ep-card">
21222        <div class="ep-header">
21223          <span class="method get">GET</span>
21224          <span class="ep-path">/api/project-history</span>
21225          <span class="auth-badge protected">Protected</span>
21226          <span class="ep-desc">Project-level scan summary</span>
21227          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21228        </div>
21229        <div class="ep-body">
21230          <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>
21231          <p class="params-heading">Query Parameters</p>
21232          <table class="params">
21233            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21234            <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>
21235          </table>
21236          <details class="schema"><summary>Response schema</summary>
21237<div class="schema-block">{
21238  "scan_count":           number,
21239  "last_scan_id":         string | null,
21240  "last_scan_timestamp":  string | null,  // ISO-8601
21241  "last_scan_code_lines": number | null,
21242  "last_git_branch":      string | null,
21243  "last_git_commit":      string | null
21244}</div></details>
21245          <p class="curl-heading">Example</p>
21246          <div class="curl-wrap">
21247            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21248  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
21249            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
21250          </div>
21251        </div>
21252      </div>
21253
21254      <div class="ep-card">
21255        <div class="ep-header">
21256          <span class="method get">GET</span>
21257          <span class="ep-path">/api/metrics/submodules</span>
21258          <span class="auth-badge protected">Protected</span>
21259          <span class="ep-desc">List known git submodules across scans</span>
21260          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21261        </div>
21262        <div class="ep-body">
21263          <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>
21264          <p class="params-heading">Query Parameters</p>
21265          <table class="params">
21266            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21267            <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>
21268          </table>
21269          <details class="schema"><summary>Response schema</summary>
21270<div class="schema-block">[{
21271  "name":          string,  // submodule name
21272  "relative_path": string   // path relative to the project root
21273}]</div></details>
21274          <p class="curl-heading">Example</p>
21275          <div class="curl-wrap">
21276            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21277  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
21278            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
21279          </div>
21280        </div>
21281      </div>
21282    </div>
21283
21284    <!-- Async Run Status -->
21285    <div class="section">
21286      <h2 class="section-title">Async Run Status</h2>
21287
21288      <div class="ep-card">
21289        <div class="ep-header">
21290          <span class="method get">GET</span>
21291          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
21292          <span class="auth-badge protected">Protected</span>
21293          <span class="ep-desc">Poll scan completion</span>
21294          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21295        </div>
21296        <div class="ep-body">
21297          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
21298          <details class="schema"><summary>Response schema</summary>
21299<div class="schema-block">// Running
21300{ "state": "running",  "elapsed_secs": number }
21301
21302// Complete
21303{ "state": "complete", "run_id": string }
21304
21305// Failed
21306{ "state": "failed",   "message": string }</div></details>
21307          <p class="curl-heading">Example</p>
21308          <div class="curl-wrap">
21309            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21310  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
21311            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
21312          </div>
21313        </div>
21314      </div>
21315
21316      <div class="ep-card">
21317        <div class="ep-header">
21318          <span class="method get">GET</span>
21319          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
21320          <span class="auth-badge protected">Protected</span>
21321          <span class="ep-desc">Poll PDF generation readiness</span>
21322          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21323        </div>
21324        <div class="ep-body">
21325          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
21326          <details class="schema"><summary>Response schema</summary>
21327<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
21328          <p class="curl-heading">Example</p>
21329          <div class="curl-wrap">
21330            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21331  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
21332            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
21333          </div>
21334        </div>
21335      </div>
21336
21337      <div class="ep-card">
21338        <div class="ep-header">
21339          <span class="method post">POST</span>
21340          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
21341          <span class="auth-badge protected">Protected</span>
21342          <span class="ep-desc">Cancel a running scan</span>
21343          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21344        </div>
21345        <div class="ep-body">
21346          <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>
21347          <p class="curl-heading">Example</p>
21348          <div class="curl-wrap">
21349            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
21350  -H "Authorization: Bearer $SLOC_API_KEY" \
21351  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
21352            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
21353          </div>
21354        </div>
21355      </div>
21356    </div>
21357
21358    <!-- Scan Profiles -->
21359    <div class="section">
21360      <h2 class="section-title">Scan Profiles</h2>
21361
21362      <div class="ep-card">
21363        <div class="ep-header">
21364          <span class="method get">GET</span>
21365          <span class="ep-path">/api/scan-profiles</span>
21366          <span class="auth-badge protected">Protected</span>
21367          <span class="ep-desc">List saved scan profiles</span>
21368          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21369        </div>
21370        <div class="ep-body">
21371          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
21372          <details class="schema"><summary>Response schema</summary>
21373<div class="schema-block">{
21374  "profiles": [{
21375    "id":         string,   // UUID
21376    "name":       string,
21377    "created_at": string,   // ISO-8601
21378    "params":     object
21379  }]
21380}</div></details>
21381          <p class="curl-heading">Example</p>
21382          <div class="curl-wrap">
21383            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21384  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
21385            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
21386          </div>
21387        </div>
21388      </div>
21389
21390      <div class="ep-card">
21391        <div class="ep-header">
21392          <span class="method post">POST</span>
21393          <span class="ep-path">/api/scan-profiles</span>
21394          <span class="auth-badge protected">Protected</span>
21395          <span class="ep-desc">Save a scan profile</span>
21396          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21397        </div>
21398        <div class="ep-body">
21399          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
21400          <p class="params-heading">Request Body (application/json)</p>
21401          <table class="params">
21402            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
21403            <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>
21404            <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>
21405          </table>
21406          <details class="schema"><summary>Response schema</summary>
21407<div class="schema-block">{ "ok": true }</div></details>
21408          <p class="curl-heading">Example</p>
21409          <div class="curl-wrap">
21410            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
21411  -H "Authorization: Bearer $SLOC_API_KEY" \
21412  -H "Content-Type: application/json" \
21413  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
21414  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
21415            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
21416          </div>
21417        </div>
21418      </div>
21419
21420      <div class="ep-card">
21421        <div class="ep-header">
21422          <span class="method delete">DELETE</span>
21423          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
21424          <span class="auth-badge protected">Protected</span>
21425          <span class="ep-desc">Delete a scan profile</span>
21426          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21427        </div>
21428        <div class="ep-body">
21429          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
21430          <p class="params-heading">Path Parameters</p>
21431          <table class="params">
21432            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21433            <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>
21434          </table>
21435          <details class="schema"><summary>Response schema</summary>
21436<div class="schema-block">{ "ok": true }</div></details>
21437          <p class="curl-heading">Example</p>
21438          <div class="curl-wrap">
21439            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
21440  -H "Authorization: Bearer $SLOC_API_KEY" \
21441  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
21442            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
21443          </div>
21444        </div>
21445      </div>
21446    </div>
21447
21448    <!-- Scheduled Scans -->
21449    <div class="section">
21450      <h2 class="section-title">Scheduled Scans</h2>
21451
21452      <div class="ep-card">
21453        <div class="ep-header">
21454          <span class="method get">GET</span>
21455          <span class="ep-path">/api/schedules</span>
21456          <span class="auth-badge protected">Protected</span>
21457          <span class="ep-desc">List configured schedules</span>
21458          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21459        </div>
21460        <div class="ep-body">
21461          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
21462          <p class="curl-heading">Example</p>
21463          <div class="curl-wrap">
21464            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21465  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21466            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
21467          </div>
21468        </div>
21469      </div>
21470
21471      <div class="ep-card">
21472        <div class="ep-header">
21473          <span class="method post">POST</span>
21474          <span class="ep-path">/api/schedules</span>
21475          <span class="auth-badge protected">Protected</span>
21476          <span class="ep-desc">Create a schedule</span>
21477          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21478        </div>
21479        <div class="ep-body">
21480          <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>
21481          <p class="curl-heading">Example</p>
21482          <div class="curl-wrap">
21483            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
21484  -H "Authorization: Bearer $SLOC_API_KEY" \
21485  -H "Content-Type: application/json" \
21486  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
21487  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21488            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
21489          </div>
21490        </div>
21491      </div>
21492
21493      <div class="ep-card">
21494        <div class="ep-header">
21495          <span class="method delete">DELETE</span>
21496          <span class="ep-path">/api/schedules</span>
21497          <span class="auth-badge protected">Protected</span>
21498          <span class="ep-desc">Delete a schedule</span>
21499          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21500        </div>
21501        <div class="ep-body">
21502          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
21503          <p class="curl-heading">Example</p>
21504          <div class="curl-wrap">
21505            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
21506  -H "Authorization: Bearer $SLOC_API_KEY" \
21507  -H "Content-Type: application/json" \
21508  -d '{"id":"&lt;schedule_id&gt;"}' \
21509  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21510            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
21511          </div>
21512        </div>
21513      </div>
21514    </div>
21515
21516    <!-- Git Browser -->
21517    <div class="section">
21518      <h2 class="section-title">Git Browser</h2>
21519
21520      <div class="ep-card">
21521        <div class="ep-header">
21522          <span class="method get">GET</span>
21523          <span class="ep-path">/api/git/refs</span>
21524          <span class="auth-badge protected">Protected</span>
21525          <span class="ep-desc">List git refs for a repository</span>
21526          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21527        </div>
21528        <div class="ep-body">
21529          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
21530          <p class="params-heading">Query Parameters</p>
21531          <table class="params">
21532            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21533            <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>
21534          </table>
21535          <p class="curl-heading">Example</p>
21536          <div class="curl-wrap">
21537            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21538  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
21539            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
21540          </div>
21541        </div>
21542      </div>
21543
21544      <div class="ep-card">
21545        <div class="ep-header">
21546          <span class="method get">GET</span>
21547          <span class="ep-path">/api/git/scan-ref</span>
21548          <span class="auth-badge protected">Protected</span>
21549          <span class="ep-desc">SLOC-scan a specific git ref</span>
21550          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21551        </div>
21552        <div class="ep-body">
21553          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
21554          <p class="params-heading">Query Parameters</p>
21555          <table class="params">
21556            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21557            <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>
21558            <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>
21559          </table>
21560          <p class="curl-heading">Example</p>
21561          <div class="curl-wrap">
21562            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21563  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
21564            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
21565          </div>
21566        </div>
21567      </div>
21568
21569      <div class="ep-card">
21570        <div class="ep-header">
21571          <span class="method get">GET</span>
21572          <span class="ep-path">/api/git/compare-refs</span>
21573          <span class="auth-badge protected">Protected</span>
21574          <span class="ep-desc">Compare SLOC across two git refs</span>
21575          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21576        </div>
21577        <div class="ep-body">
21578          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
21579          <p class="params-heading">Query Parameters</p>
21580          <table class="params">
21581            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21582            <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>
21583            <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>
21584            <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>
21585          </table>
21586          <p class="curl-heading">Example</p>
21587          <div class="curl-wrap">
21588            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21589  "<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>
21590            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
21591          </div>
21592        </div>
21593      </div>
21594    </div>
21595
21596    <!-- Webhooks -->
21597    <div class="section">
21598      <h2 class="section-title">Webhooks</h2>
21599      <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>
21600
21601      <div class="ep-card">
21602        <div class="ep-header">
21603          <span class="method post">POST</span>
21604          <span class="ep-path">/webhooks/github</span>
21605          <span class="auth-badge hmac">HMAC</span>
21606          <span class="ep-desc">GitHub push event receiver</span>
21607          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21608        </div>
21609        <div class="ep-body">
21610          <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>
21611          <p class="params-heading">Required Headers</p>
21612          <table class="params">
21613            <tr><th>Header</th><th>Value</th></tr>
21614            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
21615            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
21616            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21617          </table>
21618        </div>
21619      </div>
21620
21621      <div class="ep-card">
21622        <div class="ep-header">
21623          <span class="method post">POST</span>
21624          <span class="ep-path">/webhooks/gitlab</span>
21625          <span class="auth-badge hmac">HMAC</span>
21626          <span class="ep-desc">GitLab push event receiver</span>
21627          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21628        </div>
21629        <div class="ep-body">
21630          <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>
21631          <p class="params-heading">Required Headers</p>
21632          <table class="params">
21633            <tr><th>Header</th><th>Value</th></tr>
21634            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
21635            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
21636            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21637          </table>
21638        </div>
21639      </div>
21640
21641      <div class="ep-card">
21642        <div class="ep-header">
21643          <span class="method post">POST</span>
21644          <span class="ep-path">/webhooks/bitbucket</span>
21645          <span class="auth-badge hmac">HMAC</span>
21646          <span class="ep-desc">Bitbucket push event receiver</span>
21647          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21648        </div>
21649        <div class="ep-body">
21650          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
21651          <p class="params-heading">Required Headers</p>
21652          <table class="params">
21653            <tr><th>Header</th><th>Value</th></tr>
21654            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
21655            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21656          </table>
21657        </div>
21658      </div>
21659    </div>
21660
21661    <!-- Config -->
21662    <div class="section">
21663      <h2 class="section-title">Config Import / Export</h2>
21664
21665      <div class="ep-card">
21666        <div class="ep-header">
21667          <span class="method get">GET</span>
21668          <span class="ep-path">/export-config</span>
21669          <span class="auth-badge protected">Protected</span>
21670          <span class="ep-desc">Export server configuration as JSON</span>
21671          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21672        </div>
21673        <div class="ep-body">
21674          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
21675          <p class="curl-heading">Example</p>
21676          <div class="curl-wrap">
21677            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21678  -o config.json \
21679  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
21680            <button class="curl-copy-btn" data-target="c-export">Copy</button>
21681          </div>
21682        </div>
21683      </div>
21684
21685      <div class="ep-card">
21686        <div class="ep-header">
21687          <span class="method post">POST</span>
21688          <span class="ep-path">/import-config</span>
21689          <span class="auth-badge protected">Protected</span>
21690          <span class="ep-desc">Import server configuration</span>
21691          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21692        </div>
21693        <div class="ep-body">
21694          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
21695          <p class="curl-heading">Example</p>
21696          <div class="curl-wrap">
21697            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
21698  -H "Authorization: Bearer $SLOC_API_KEY" \
21699  -H "Content-Type: application/json" \
21700  -d @config.json \
21701  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
21702            <button class="curl-copy-btn" data-target="c-import">Copy</button>
21703          </div>
21704        </div>
21705      </div>
21706    </div>
21707
21708    <!-- CI Ingest -->
21709    <div class="section">
21710      <h2 class="section-title">CI Ingest</h2>
21711
21712      <div class="ep-card">
21713        <div class="ep-header">
21714          <span class="method post">POST</span>
21715          <span class="ep-path">/api/ingest</span>
21716          <span class="auth-badge protected">Protected</span>
21717          <span class="ep-desc">Push a pre-computed scan result from CI</span>
21718          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21719        </div>
21720        <div class="ep-body">
21721          <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>
21722          <p class="params-heading">Query Parameters</p>
21723          <table class="params">
21724            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21725            <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>
21726          </table>
21727          <p class="params-heading">Request Body (application/json)</p>
21728          <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>
21729          <details class="schema"><summary>Response schema</summary>
21730<div class="schema-block">// 201 Created
21731{
21732  "run_id":   string,  // UUID of the ingested run
21733  "view_url": string   // relative URL to the report page
21734}</div></details>
21735          <p class="curl-heading">Example</p>
21736          <div class="curl-wrap">
21737            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
21738  -H "Authorization: Bearer $SLOC_API_KEY" \
21739  -H "Content-Type: application/json" \
21740  -d @result.json \
21741  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
21742            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
21743          </div>
21744        </div>
21745      </div>
21746    </div>
21747
21748    <!-- Artifact Download -->
21749    <div class="section">
21750      <h2 class="section-title">Artifact Download</h2>
21751
21752      <div class="ep-card">
21753        <div class="ep-header">
21754          <span class="method get">GET</span>
21755          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
21756          <span class="auth-badge protected">Protected</span>
21757          <span class="ep-desc">Download or view a scan artifact</span>
21758          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21759        </div>
21760        <div class="ep-body">
21761          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
21762          <p class="params-heading">Path Parameters</p>
21763          <table class="params">
21764            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21765            <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>
21766            <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>
21767          </table>
21768          <p class="params-heading">Query Parameters</p>
21769          <table class="params">
21770            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21771            <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>
21772          </table>
21773          <p class="curl-heading">Example — download JSON result</p>
21774          <div class="curl-wrap">
21775            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21776  -o result.json \
21777  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
21778            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
21779          </div>
21780        </div>
21781      </div>
21782    </div>
21783
21784    <!-- Embed Widget -->
21785    <div class="section">
21786      <h2 class="section-title">Embed Widget</h2>
21787
21788      <div class="ep-card">
21789        <div class="ep-header">
21790          <span class="method get">GET</span>
21791          <span class="ep-path">/embed/summary</span>
21792          <span class="auth-badge protected">Protected</span>
21793          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
21794          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21795        </div>
21796        <div class="ep-body">
21797          <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>
21798          <p class="params-heading">Query Parameters</p>
21799          <table class="params">
21800            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21801            <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>
21802            <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>
21803          </table>
21804          <p class="curl-heading">Example</p>
21805          <div class="curl-wrap">
21806            <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"
21807        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
21808            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
21809          </div>
21810        </div>
21811      </div>
21812    </div>
21813
21814    <!-- Confluence Integration -->
21815    <div class="section">
21816      <h2 class="section-title">Confluence Integration</h2>
21817
21818      <div class="ep-card">
21819        <div class="ep-header">
21820          <span class="method get">GET</span>
21821          <span class="ep-path">/api/confluence/config</span>
21822          <span class="auth-badge protected">Protected</span>
21823          <span class="ep-desc">Get current Confluence configuration</span>
21824          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21825        </div>
21826        <div class="ep-body">
21827          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
21828          <details class="schema"><summary>Response schema</summary>
21829<div class="schema-block">{
21830  "configured":     boolean,
21831  "tier":           "cloud" | "server",
21832  "base_url":       string,
21833  "username":       string,
21834  "api_token_set":  boolean,
21835  "space_key":      string,
21836  "parent_page_id": string | null,
21837  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
21838}</div></details>
21839          <p class="curl-heading">Example</p>
21840          <div class="curl-wrap">
21841            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21842  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
21843            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
21844          </div>
21845        </div>
21846      </div>
21847
21848      <div class="ep-card">
21849        <div class="ep-header">
21850          <span class="method post">POST</span>
21851          <span class="ep-path">/api/confluence/config</span>
21852          <span class="auth-badge protected">Protected</span>
21853          <span class="ep-desc">Save Confluence configuration</span>
21854          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21855        </div>
21856        <div class="ep-body">
21857          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
21858          <p class="params-heading">Request Body (application/json)</p>
21859          <table class="params">
21860            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
21861            <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>
21862            <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>
21863            <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>
21864            <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>
21865            <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>
21866            <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>
21867            <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>
21868          </table>
21869          <details class="schema"><summary>Response schema</summary>
21870<div class="schema-block">{ "ok": true }</div></details>
21871          <p class="curl-heading">Example</p>
21872          <div class="curl-wrap">
21873            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
21874  -H "Authorization: Bearer $SLOC_API_KEY" \
21875  -H "Content-Type: application/json" \
21876  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
21877  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
21878            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
21879          </div>
21880        </div>
21881      </div>
21882
21883      <div class="ep-card">
21884        <div class="ep-header">
21885          <span class="method post">POST</span>
21886          <span class="ep-path">/api/confluence/test</span>
21887          <span class="auth-badge protected">Protected</span>
21888          <span class="ep-desc">Test Confluence connection</span>
21889          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21890        </div>
21891        <div class="ep-body">
21892          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
21893          <details class="schema"><summary>Response schema</summary>
21894<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
21895          <p class="curl-heading">Example</p>
21896          <div class="curl-wrap">
21897            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
21898  -H "Authorization: Bearer $SLOC_API_KEY" \
21899  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
21900            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
21901          </div>
21902        </div>
21903      </div>
21904
21905      <div class="ep-card">
21906        <div class="ep-header">
21907          <span class="method post">POST</span>
21908          <span class="ep-path">/api/confluence/post</span>
21909          <span class="auth-badge protected">Protected</span>
21910          <span class="ep-desc">Publish a scan report to Confluence</span>
21911          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21912        </div>
21913        <div class="ep-body">
21914          <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>
21915          <p class="params-heading">Request Body (application/json)</p>
21916          <table class="params">
21917            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
21918            <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>
21919            <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>
21920            <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>
21921          </table>
21922          <details class="schema"><summary>Response schema</summary>
21923<div class="schema-block">// 200 OK
21924{ "ok": true, "page_id": string }
21925
21926// 400 / 502 on error
21927{ "ok": false, "error": string }</div></details>
21928          <p class="curl-heading">Example</p>
21929          <div class="curl-wrap">
21930            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
21931  -H "Authorization: Bearer $SLOC_API_KEY" \
21932  -H "Content-Type: application/json" \
21933  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
21934  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
21935            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
21936          </div>
21937        </div>
21938      </div>
21939
21940      <div class="ep-card">
21941        <div class="ep-header">
21942          <span class="method get">GET</span>
21943          <span class="ep-path">/api/confluence/wiki-markup</span>
21944          <span class="auth-badge protected">Protected</span>
21945          <span class="ep-desc">Get Confluence wiki markup for a run</span>
21946          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21947        </div>
21948        <div class="ep-body">
21949          <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>
21950          <p class="params-heading">Query Parameters</p>
21951          <table class="params">
21952            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21953            <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>
21954          </table>
21955          <p class="curl-heading">Example</p>
21956          <div class="curl-wrap">
21957            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21958  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
21959            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
21960          </div>
21961        </div>
21962      </div>
21963    </div>
21964
21965    <!-- Authentication -->
21966    <div class="section">
21967      <h2 class="section-title">Authentication</h2>
21968      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
21969
21970      <div class="ep-card">
21971        <div class="ep-header">
21972          <span class="method get">GET</span>
21973          <span class="ep-path">/auth/login</span>
21974          <span class="auth-badge public">Public</span>
21975          <span class="ep-desc">Login page</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">Returns the HTML login form. Redirects to <code>/</code> immediately when no API key is configured on the server.</p>
21980          <p class="params-heading">Query Parameters</p>
21981          <table class="params">
21982            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21983            <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>
21984            <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>
21985          </table>
21986        </div>
21987      </div>
21988
21989      <div class="ep-card">
21990        <div class="ep-header">
21991          <span class="method post">POST</span>
21992          <span class="ep-path">/auth/login</span>
21993          <span class="auth-badge public">Public</span>
21994          <span class="ep-desc">Submit credentials and get a session cookie</span>
21995          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21996        </div>
21997        <div class="ep-body">
21998          <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>
21999          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
22000          <table class="params">
22001            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22002            <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>
22003            <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>
22004          </table>
22005          <p class="curl-heading">Example</p>
22006          <div class="curl-wrap">
22007            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
22008  -d "key=$SLOC_API_KEY&amp;next=/" \
22009  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
22010            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
22011          </div>
22012        </div>
22013      </div>
22014    </div>
22015
22016    <!-- Coverage Suggestion -->
22017    <div class="section">
22018      <h2 class="section-title">Coverage Suggestion</h2>
22019
22020      <div class="ep-card">
22021        <div class="ep-header">
22022          <span class="method get">GET</span>
22023          <span class="ep-path">/api/suggest-coverage</span>
22024          <span class="auth-badge protected">Protected</span>
22025          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
22026          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22027        </div>
22028        <div class="ep-body">
22029          <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>
22030          <p class="params-heading">Query Parameters</p>
22031          <table class="params">
22032            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22033            <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>
22034          </table>
22035          <details class="schema"><summary>Response schema</summary>
22036<div class="schema-block">{
22037  "found": string | null,  // absolute path to the coverage file, if detected
22038  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
22039  "hint":  string | null   // shell command to generate coverage if not found
22040}</div></details>
22041          <p class="curl-heading">Example</p>
22042          <div class="curl-wrap">
22043            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22044  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
22045            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
22046          </div>
22047        </div>
22048      </div>
22049    </div>
22050
22051  </div>
22052
22053  <footer class="site-footer">
22054    local code analysis - metrics, history and reports
22055    &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>
22056    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22057    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22058    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22059    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22060  </footer>
22061
22062  <script nonce="{{ csp_nonce }}">
22063    (function () {
22064      var base = window.location.origin;
22065      document.getElementById('base-url').textContent = base;
22066      document.querySelectorAll('.base-url-slot').forEach(function (el) {
22067        el.textContent = base;
22068      });
22069
22070      document.querySelectorAll('.ep-header').forEach(function (hdr) {
22071        hdr.addEventListener('click', function () {
22072          hdr.closest('.ep-card').classList.toggle('open');
22073        });
22074      });
22075
22076      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
22077        btn.addEventListener('click', function () {
22078          var targetId = btn.dataset.target;
22079          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
22080          if (!pre) return;
22081          navigator.clipboard.writeText(pre.textContent).then(function () {
22082            btn.textContent = 'Copied!';
22083            btn.classList.add('copied');
22084            setTimeout(function () {
22085              btn.textContent = 'Copy';
22086              btn.classList.remove('copied');
22087            }, 2000);
22088          });
22089        });
22090      });
22091
22092      var storageKey = 'oxide-sloc-theme';
22093      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
22094      var themeBtn = document.getElementById('theme-toggle');
22095      if (themeBtn) {
22096        themeBtn.addEventListener('click', function () {
22097          var dark = document.body.classList.toggle('dark-theme');
22098          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
22099        });
22100      }
22101      (function() {
22102        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'}];
22103        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);});}
22104        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22105        var btn=document.getElementById('settings-btn');if(!btn)return;
22106        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22107        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>';
22108        document.body.appendChild(m);
22109        var g=document.getElementById('scheme-grid');
22110        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);});
22111        var cl=document.getElementById('settings-close');
22112        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);
22113        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');});
22114        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22115        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22116      })();
22117      (function randomizeWatermarks() {
22118        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22119        if (!wms.length) return;
22120        var placed = [];
22121        function tooClose(top, left) {
22122          for (var i = 0; i < placed.length; i++) {
22123            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22124            if (dt < 16 && dl < 12) return true;
22125          }
22126          return false;
22127        }
22128        function pick(leftBand) {
22129          for (var attempt = 0; attempt < 50; attempt++) {
22130            var top = Math.random() * 88 + 2;
22131            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22132            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22133          }
22134          var top = Math.random() * 88 + 2;
22135          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22136          placed.push([top, left]); return [top, left];
22137        }
22138        var half = Math.floor(wms.length / 2);
22139        wms.forEach(function (img, i) {
22140          var pos = pick(i < half);
22141          var size = Math.floor(Math.random() * 100 + 120);
22142          var rot = (Math.random() * 360).toFixed(1);
22143          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22144          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;
22145        });
22146      })();
22147      (function spawnCodeParticles() {
22148        var container = document.getElementById('code-particles');
22149        if (!container) return;
22150        var snippets = [
22151          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22152          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22153          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22154          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22155          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22156        ];
22157        var count = 38;
22158        for (var i = 0; i < count; i++) {
22159          (function(idx) {
22160            var el = document.createElement('span');
22161            el.className = 'code-particle';
22162            el.textContent = snippets[idx % snippets.length];
22163            var left = Math.random() * 94 + 2;
22164            var top = Math.random() * 88 + 6;
22165            var dur = (Math.random() * 10 + 9).toFixed(1);
22166            var delay = (Math.random() * 18).toFixed(1);
22167            var rot = (Math.random() * 26 - 13).toFixed(1);
22168            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22169            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
22170            container.appendChild(el);
22171          })(i);
22172        }
22173      })();
22174    }());
22175  </script>
22176</body>
22177</html>
22178"##,
22179    ext = "html"
22180)]
22181struct ApiDocsTemplate {
22182    has_api_key: bool,
22183    csp_nonce: String,
22184    version: &'static str,
22185}