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 git_commit_url = run
4150        .git_remote_url
4151        .as_deref()
4152        .zip(run.git_commit_long.as_deref())
4153        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4154    let scan_performed_by = format!(
4155        "{} / {}",
4156        run.environment.initiator_username, run.environment.initiator_hostname
4157    );
4158    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc, false);
4159    let generated_display = fmt_la_time_meta(run.tool.timestamp_utc, true);
4160    let os_display = format!(
4161        "{} / {}",
4162        run.environment.operating_system, run.environment.architecture
4163    );
4164    let test_count = run.summary_totals.test_count;
4165
4166    let template = ResultTemplate {
4167        version: env!("CARGO_PKG_VERSION"),
4168        report_title: run.effective_configuration.reporting.report_title.clone(),
4169        project_path: project_path.clone(),
4170        output_dir: display_path(&artifacts.output_dir),
4171        run_id: run_id.to_owned(),
4172        run_id_short: run_id
4173            .split('-')
4174            .next_back()
4175            .unwrap_or(run_id)
4176            .chars()
4177            .take(7)
4178            .collect(),
4179        files_analyzed,
4180        files_skipped,
4181        physical_lines,
4182        code_lines,
4183        comment_lines,
4184        blank_lines,
4185        mixed_lines,
4186        functions,
4187        classes,
4188        variables,
4189        imports,
4190        html_url: artifacts
4191            .html_path
4192            .as_ref()
4193            .map(|_| format!("/runs/html/{run_id}")),
4194        pdf_url: artifacts
4195            .pdf_path
4196            .as_ref()
4197            .map(|_| format!("/runs/pdf/{run_id}")),
4198        json_url: artifacts
4199            .json_path
4200            .as_ref()
4201            .map(|_| format!("/runs/json/{run_id}")),
4202        html_download_url: artifacts
4203            .html_path
4204            .as_ref()
4205            .map(|_| format!("/runs/html/{run_id}?download=1")),
4206        pdf_download_url: artifacts
4207            .pdf_path
4208            .as_ref()
4209            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4210        json_download_url: artifacts
4211            .json_path
4212            .as_ref()
4213            .map(|_| format!("/runs/json/{run_id}?download=1")),
4214        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4215        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4216        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4217        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4218        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4219        prev_fa_str,
4220        prev_fs_str,
4221        prev_pl_str,
4222        prev_cl_str,
4223        prev_cml_str,
4224        prev_bl_str,
4225        delta_fa_str,
4226        delta_fa_class,
4227        delta_fs_str,
4228        delta_fs_class,
4229        delta_pl_str,
4230        delta_pl_class,
4231        delta_cl_str,
4232        delta_cl_class,
4233        delta_cml_str,
4234        delta_cml_class,
4235        delta_bl_str,
4236        delta_bl_class,
4237        delta_lines_added,
4238        delta_lines_removed,
4239        delta_lines_net_str,
4240        delta_lines_net_class,
4241        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4242        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4243        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4244        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4245        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4246            d.file_deltas
4247                .iter()
4248                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4249                .map(|f| {
4250                    #[allow(clippy::cast_sign_loss)]
4251                    let n = f.current_code as u64;
4252                    n
4253                })
4254                .sum()
4255        }),
4256        git_branch,
4257        git_commit,
4258        git_commit_long,
4259        git_author,
4260        git_commit_url,
4261        scan_performed_by,
4262        scan_time_display,
4263        generated_display,
4264        os_display,
4265        test_count,
4266        current_scan_number: prev_scan_count + 1,
4267        prev_scan_count,
4268        submodule_rows: run
4269            .submodule_summaries
4270            .iter()
4271            .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
4272            .collect(),
4273        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4274        scan_config_url: format!("/runs/scan-config/{run_id}"),
4275        lang_chart_json: {
4276            let entries: Vec<String> = run
4277                .totals_by_language
4278                .iter()
4279                .take(12)
4280                .map(|l| {
4281                    let name = l
4282                        .language
4283                        .display_name()
4284                        .replace('\\', "\\\\")
4285                        .replace('"', "\\\"");
4286                    format!(
4287                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4288                        name,
4289                        l.code_lines,
4290                        l.comment_lines,
4291                        l.blank_lines,
4292                        l.functions,
4293                        l.classes,
4294                        l.variables,
4295                        l.imports,
4296                        l.files,
4297                    )
4298                })
4299                .collect();
4300            format!("[{}]", entries.join(","))
4301        },
4302        scatter_chart_json: {
4303            let entries: Vec<String> = run
4304                .totals_by_language
4305                .iter()
4306                .map(|l| {
4307                    let name = l
4308                        .language
4309                        .display_name()
4310                        .replace('\\', "\\\\")
4311                        .replace('"', "\\\"");
4312                    format!(
4313                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4314                        name, l.files, l.code_lines, l.total_physical_lines,
4315                    )
4316                })
4317                .collect();
4318            format!("[{}]", entries.join(","))
4319        },
4320        semantic_chart_json: {
4321            let entries: Vec<String> = run
4322                .totals_by_language
4323                .iter()
4324                .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
4325                .map(|l| {
4326                    let name = l
4327                        .language
4328                        .display_name()
4329                        .replace('\\', "\\\\")
4330                        .replace('"', "\\\"");
4331                    format!(
4332                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
4333                        name, l.functions, l.classes, l.variables, l.imports,
4334                    )
4335                })
4336                .collect();
4337            format!("[{}]", entries.join(","))
4338        },
4339        submodule_chart_json: {
4340            let entries: Vec<String> = run
4341                .submodule_summaries
4342                .iter()
4343                .map(|s| {
4344                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
4345                    format!(
4346                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
4347                        name,
4348                        s.code_lines,
4349                        s.comment_lines,
4350                        s.blank_lines,
4351                        s.total_physical_lines,
4352                        s.files_analyzed,
4353                    )
4354                })
4355                .collect();
4356            format!("[{}]", entries.join(","))
4357        },
4358        has_submodule_data: !run.submodule_summaries.is_empty(),
4359        has_semantic_data: run
4360            .totals_by_language
4361            .iter()
4362            .any(|l| l.functions > 0 || l.classes > 0),
4363        csp_nonce: csp_nonce.to_owned(),
4364        confluence_configured,
4365        server_mode,
4366        report_header_footer: run
4367            .effective_configuration
4368            .reporting
4369            .report_header_footer
4370            .clone(),
4371    };
4372
4373    Html(
4374        template
4375            .render()
4376            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4377    )
4378    .into_response()
4379}
4380
4381fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
4382    let slug: String = report_title
4383        .chars()
4384        .map(|c| {
4385            if c.is_alphanumeric() || c == '-' {
4386                c.to_ascii_lowercase()
4387            } else {
4388                '_'
4389            }
4390        })
4391        .collect::<String>()
4392        .split('_')
4393        .filter(|s| !s.is_empty())
4394        .collect::<Vec<_>>()
4395        .join("_");
4396
4397    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
4398
4399    if slug.is_empty() {
4400        format!("report_{short_id}.pdf")
4401    } else {
4402        format!("{slug}_{short_id}.pdf")
4403    }
4404}
4405
4406#[derive(Serialize)]
4407struct PdfStatusResponse {
4408    ready: bool,
4409}
4410
4411/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
4412/// Clients poll this to update the button state without page reloads.
4413async fn pdf_status_handler(
4414    State(state): State<AppState>,
4415    AxumPath(run_id): AxumPath<String>,
4416) -> Response {
4417    let pdf_path = {
4418        let registry = state.artifacts.lock().await;
4419        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
4420    };
4421    let pdf_path = if pdf_path.is_some() {
4422        pdf_path
4423    } else {
4424        let reg = state.registry.lock().await;
4425        reg.find_by_run_id(&run_id)
4426            .map(recover_artifacts_from_registry)
4427            .and_then(|a| a.pdf_path)
4428    };
4429    let ready = pdf_path.is_some_and(|p| p.exists());
4430    Json(PdfStatusResponse { ready }).into_response()
4431}
4432
4433/// GET /`api/runs/:run_id/bundle`
4434///
4435/// Streams a gzip-compressed tar archive containing every artifact in the run's
4436/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
4437/// is built in memory so it never touches a temp file.
4438async fn download_bundle_handler(
4439    State(state): State<AppState>,
4440    AxumPath(run_id): AxumPath<String>,
4441) -> Response {
4442    // Resolve output directory from in-memory cache or persisted registry.
4443    let output_dir = {
4444        let cache = state.artifacts.lock().await;
4445        cache.get(&run_id).map(|a| a.output_dir.clone())
4446    };
4447    let output_dir = if let Some(d) = output_dir {
4448        d
4449    } else {
4450        let reg = state.registry.lock().await;
4451        match reg.find_by_run_id(&run_id) {
4452            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
4453            None => {
4454                return (
4455                    StatusCode::NOT_FOUND,
4456                    Json(serde_json::json!({"error": "Run not found"})),
4457                )
4458                    .into_response();
4459            }
4460        }
4461    };
4462
4463    if !output_dir.exists() {
4464        return (
4465            StatusCode::NOT_FOUND,
4466            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
4467        )
4468            .into_response();
4469    }
4470
4471    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
4472    let run_id_clone = run_id.clone();
4473    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
4474        use flate2::{write::GzEncoder, Compression};
4475        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
4476        {
4477            let mut tar = tar::Builder::new(&mut enc);
4478            tar.follow_symlinks(false);
4479            // Append every regular file in the output directory, skipping
4480            // sub-directories (the output dir is always flat).
4481            if let Ok(entries) = std::fs::read_dir(&output_dir) {
4482                for entry in entries.filter_map(Result::ok) {
4483                    let p = entry.path();
4484                    if p.is_file() {
4485                        let name = p.file_name().unwrap_or_default().to_string_lossy();
4486                        let archive_path = format!("{run_id_clone}/{name}");
4487                        tar.append_path_with_name(&p, &archive_path)?;
4488                    }
4489                }
4490            }
4491            tar.finish()?;
4492        }
4493        Ok(enc.finish()?)
4494    })
4495    .await;
4496
4497    match archive_result {
4498        Ok(Ok(bytes)) => {
4499            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
4500            axum::response::Response::builder()
4501                .status(StatusCode::OK)
4502                .header("Content-Type", "application/gzip")
4503                .header(
4504                    "Content-Disposition",
4505                    format!("attachment; filename=\"{filename}\""),
4506                )
4507                .header("Content-Length", bytes.len().to_string())
4508                .body(axum::body::Body::from(bytes))
4509                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
4510        }
4511        Ok(Err(e)) => (
4512            StatusCode::INTERNAL_SERVER_ERROR,
4513            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
4514        )
4515            .into_response(),
4516        Err(e) => (
4517            StatusCode::INTERNAL_SERVER_ERROR,
4518            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
4519        )
4520            .into_response(),
4521    }
4522}
4523
4524/// DELETE /`api/runs/:run_id`
4525///
4526/// Removes all on-disk artifacts for the run and purges the run from the
4527/// in-memory cache and the persisted registry. Returns 204 on success.
4528async fn delete_run_handler(
4529    State(state): State<AppState>,
4530    AxumPath(run_id): AxumPath<String>,
4531) -> Response {
4532    // Resolve output directory.
4533    let output_dir = {
4534        let mut cache = state.artifacts.lock().await;
4535        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
4536        cache.remove(&run_id);
4537        dir
4538    };
4539    let output_dir = if let Some(d) = output_dir {
4540        d
4541    } else {
4542        let reg = state.registry.lock().await;
4543        reg.find_by_run_id(&run_id)
4544            .map(|e| recover_artifacts_from_registry(e).output_dir)
4545            .unwrap_or_default()
4546    };
4547
4548    // Remove from persisted registry.
4549    {
4550        let mut reg = state.registry.lock().await;
4551        reg.entries.retain(|e| e.run_id != run_id);
4552        let _ = reg.save(&state.registry_path);
4553    }
4554
4555    // Delete on-disk artifacts.
4556    if output_dir.exists() {
4557        if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
4558            return (
4559                StatusCode::INTERNAL_SERVER_ERROR,
4560                Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
4561            )
4562                .into_response();
4563        }
4564    }
4565
4566    StatusCode::NO_CONTENT.into_response()
4567}
4568
4569/// POST /api/runs/cleanup
4570///
4571/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
4572/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
4573async fn cleanup_runs_handler(
4574    State(state): State<AppState>,
4575    Json(body): Json<serde_json::Value>,
4576) -> Response {
4577    let days = body
4578        .get("older_than_days")
4579        .and_then(serde_json::Value::as_u64)
4580        .unwrap_or(30)
4581        .max(1);
4582
4583    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
4584
4585    // Collect expired entries from the registry.
4586    let expired: Vec<(String, PathBuf)> = {
4587        let reg = state.registry.lock().await;
4588        reg.entries
4589            .iter()
4590            .filter(|e| e.timestamp_utc < cutoff)
4591            .map(|e| {
4592                let arts = recover_artifacts_from_registry(e);
4593                (e.run_id.clone(), arts.output_dir)
4594            })
4595            .collect()
4596    };
4597
4598    let mut deleted = 0usize;
4599    for (run_id, output_dir) in &expired {
4600        // Remove from in-memory cache.
4601        state.artifacts.lock().await.remove(run_id);
4602        // Delete on-disk artifacts (non-fatal if already gone).
4603        if output_dir.exists() {
4604            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
4605                eprintln!(
4606                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
4607                    output_dir.display()
4608                );
4609                continue;
4610            }
4611        }
4612        deleted += 1;
4613    }
4614
4615    // Purge expired run IDs from the registry in one pass.
4616    let expired_ids: std::collections::HashSet<&str> =
4617        expired.iter().map(|(id, _)| id.as_str()).collect();
4618    {
4619        let mut reg = state.registry.lock().await;
4620        reg.entries
4621            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
4622        let _ = reg.save(&state.registry_path);
4623    }
4624
4625    Json(serde_json::json!({ "deleted": deleted })).into_response()
4626}
4627
4628/// Serve the HTML artifact for a run — view or download.
4629/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
4630/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
4631/// current-request Content-Security-Policy nonce check.
4632fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
4633    // Find the first nonce value that was baked in at render time.
4634    let Some(start) = html.find("nonce=\"") else {
4635        // Reports generated before nonce support was added have bare <style> and <script>
4636        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
4637        // the inline blocks — without it the browser blocks all CSS and JS.
4638        return html
4639            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
4640            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
4641    };
4642    let value_start = start + 7; // len(r#"nonce=""#) == 7
4643    let Some(end_offset) = html[value_start..].find('"') else {
4644        return html.to_owned();
4645    };
4646    let old_nonce = &html[value_start..value_start + end_offset];
4647    html.replace(
4648        &format!("nonce=\"{old_nonce}\""),
4649        &format!("nonce=\"{new_nonce}\""),
4650    )
4651}
4652
4653fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4654    match fs::read_to_string(path) {
4655        Ok(raw) => {
4656            // Patch the saved nonce so inline styles/scripts pass CSP.
4657            let content = patch_html_nonce(&raw, csp_nonce);
4658            if wants_download {
4659                (
4660                    [
4661                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
4662                        (
4663                            header::CONTENT_DISPOSITION,
4664                            "attachment; filename=report.html",
4665                        ),
4666                    ],
4667                    content,
4668                )
4669                    .into_response()
4670            } else {
4671                Html(content).into_response()
4672            }
4673        }
4674        Err(err) => {
4675            let filename = path.file_name().map_or_else(
4676                || "report.html".to_string(),
4677                |n| n.to_string_lossy().into_owned(),
4678            );
4679            let msg = format!(
4680                "HTML report '{filename}' could not be read.\n\n\
4681                 Error: {err}\n\n\
4682                 If you moved or renamed the output folder, the stored path is now stale. \
4683                 Use 'Open HTML folder' from the results page to browse the output directory."
4684            );
4685            let html = ErrorTemplate {
4686                message: msg,
4687                last_report_url: Some("/view-reports".to_string()),
4688                last_report_label: Some("View Reports".to_string()),
4689                csp_nonce: csp_nonce.to_owned(),
4690                version: env!("CARGO_PKG_VERSION"),
4691            }
4692            .render()
4693            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4694            (StatusCode::NOT_FOUND, Html(html)).into_response()
4695        }
4696    }
4697}
4698
4699/// Serve the PDF artifact for a run — inline or download.
4700fn serve_pdf_artifact(
4701    path: &Path,
4702    report_title: &str,
4703    run_id: &str,
4704    wants_download: bool,
4705    csp_nonce: &str,
4706) -> Response {
4707    match fs::read(path) {
4708        Ok(bytes) => {
4709            let filename = build_pdf_filename(report_title, run_id);
4710            let disposition = if wants_download {
4711                format!("attachment; filename=\"{filename}\"")
4712            } else {
4713                format!("inline; filename=\"{filename}\"")
4714            };
4715            (
4716                [
4717                    (header::CONTENT_TYPE, "application/pdf".to_string()),
4718                    (header::CONTENT_DISPOSITION, disposition),
4719                ],
4720                bytes,
4721            )
4722                .into_response()
4723        }
4724        Err(err) => {
4725            let filename = path.file_name().map_or_else(
4726                || "report.pdf".to_string(),
4727                |n| n.to_string_lossy().into_owned(),
4728            );
4729            let msg = format!(
4730                "PDF report '{filename}' could not be read.\n\n\
4731                 Error: {err}\n\n\
4732                 If you moved or renamed the output folder, the stored path is now stale. \
4733                 Use 'Open PDF folder' from the results page to browse the output directory."
4734            );
4735            let html = ErrorTemplate {
4736                message: msg,
4737                last_report_url: Some("/view-reports".to_string()),
4738                last_report_label: Some("View Reports".to_string()),
4739                csp_nonce: csp_nonce.to_owned(),
4740                version: env!("CARGO_PKG_VERSION"),
4741            }
4742            .render()
4743            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4744            (StatusCode::NOT_FOUND, Html(html)).into_response()
4745        }
4746    }
4747}
4748
4749/// Serve the JSON artifact for a run — view or download.
4750fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
4751    match fs::read(path) {
4752        Ok(bytes) => {
4753            if wants_download {
4754                (
4755                    [
4756                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
4757                        (
4758                            header::CONTENT_DISPOSITION,
4759                            "attachment; filename=result.json",
4760                        ),
4761                    ],
4762                    bytes,
4763                )
4764                    .into_response()
4765            } else {
4766                (
4767                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
4768                    bytes,
4769                )
4770                    .into_response()
4771            }
4772        }
4773        Err(err) => {
4774            let filename = path.file_name().map_or_else(
4775                || "result.json".to_string(),
4776                |n| n.to_string_lossy().into_owned(),
4777            );
4778            let msg = format!(
4779                "JSON result '{filename}' could not be read.\n\n\
4780                 Error: {err}\n\n\
4781                 If you moved or renamed the output folder, the stored path is now stale. \
4782                 Use 'Open JSON folder' from the results page to browse the output directory."
4783            );
4784            let html = ErrorTemplate {
4785                message: msg,
4786                last_report_url: Some("/view-reports".to_string()),
4787                last_report_label: Some("View Reports".to_string()),
4788                csp_nonce: csp_nonce.to_owned(),
4789                version: env!("CARGO_PKG_VERSION"),
4790            }
4791            .render()
4792            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
4793            (StatusCode::NOT_FOUND, Html(html)).into_response()
4794        }
4795    }
4796}
4797
4798/// Recover a `RunArtifacts` from the persisted registry for a run ID.
4799fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
4800    let output_dir = entry
4801        .html_path
4802        .as_ref()
4803        .or(entry.json_path.as_ref())
4804        .or(entry.pdf_path.as_ref())
4805        .or(entry.csv_path.as_ref())
4806        .or(entry.xlsx_path.as_ref())
4807        .and_then(|p| p.parent().map(PathBuf::from))
4808        .unwrap_or_default();
4809    // Recover pdf_path: use the persisted one, or look for report.pdf
4810    // adjacent to html/json if only the old entries lack it.
4811    let pdf_path = entry.pdf_path.clone().or_else(|| {
4812        let candidate = output_dir.join("report.pdf");
4813        candidate.exists().then_some(candidate)
4814    });
4815    // csv_path / xlsx_path: persisted paths take precedence; fall back to
4816    // scanning the run directory for files matching the expected patterns so
4817    // that runs created before this feature still surface their artifacts.
4818    let csv_path = entry.csv_path.clone().or_else(|| {
4819        fs::read_dir(&output_dir).ok().and_then(|entries| {
4820            entries
4821                .filter_map(std::result::Result::ok)
4822                .find(|e| {
4823                    let n = e.file_name();
4824                    let n = n.to_string_lossy();
4825                    n.starts_with("report_") && n.ends_with(".csv")
4826                })
4827                .map(|e| e.path())
4828        })
4829    });
4830    let xlsx_path = entry.xlsx_path.clone().or_else(|| {
4831        fs::read_dir(&output_dir).ok().and_then(|entries| {
4832            entries
4833                .filter_map(std::result::Result::ok)
4834                .find(|e| {
4835                    let n = e.file_name();
4836                    let n = n.to_string_lossy();
4837                    n.starts_with("report_") && n.ends_with(".xlsx")
4838                })
4839                .map(|e| e.path())
4840        })
4841    });
4842    RunArtifacts {
4843        output_dir: output_dir.clone(),
4844        html_path: entry.html_path.clone(),
4845        pdf_path,
4846        json_path: entry.json_path.clone(),
4847        csv_path,
4848        xlsx_path,
4849        scan_config_path: find_scan_config_in_dir(&output_dir),
4850        report_title: entry.project_label.clone(),
4851        result_context: RunResultContext::default(),
4852    }
4853}
4854
4855#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
4856async fn resolve_artifact_set(
4857    state: &AppState,
4858    run_id: &str,
4859    csp_nonce: &str,
4860) -> Result<RunArtifacts, Response> {
4861    let cached = state.artifacts.lock().await.get(run_id).cloned();
4862    if let Some(a) = cached {
4863        return Ok(a);
4864    }
4865    let reg = state.registry.lock().await;
4866    if let Some(entry) = reg.find_by_run_id(run_id) {
4867        return Ok(recover_artifacts_from_registry(entry));
4868    }
4869    drop(reg);
4870    let short_id = &run_id[..run_id.len().min(8)];
4871    let hint = if matches!(
4872        run_id,
4873        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
4874    ) {
4875        format!(
4876            " The URL format appears to be reversed — \
4877             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
4878             Use the View Reports page to navigate to your scan."
4879        )
4880    } else {
4881        " The report may have been deleted or the report directory moved. \
4882         Use View Reports to browse your scan history."
4883            .to_string()
4884    };
4885    let error_html = ErrorTemplate {
4886        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
4887        last_report_url: Some("/view-reports".to_string()),
4888        last_report_label: Some("View Reports".to_string()),
4889        csp_nonce: csp_nonce.to_owned(),
4890        version: env!("CARGO_PKG_VERSION"),
4891    }
4892    .render()
4893    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4894    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
4895}
4896
4897#[allow(clippy::too_many_lines)] // bulk is an inline HTML string for the PDF-waiting page
4898async fn artifact_handler(
4899    State(state): State<AppState>,
4900    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4901    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
4902    Query(query): Query<ArtifactQuery>,
4903) -> Response {
4904    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
4905        Ok(a) => a,
4906        Err(r) => return r,
4907    };
4908
4909    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
4910
4911    match artifact.as_str() {
4912        "html" => {
4913            let Some(path) = artifact_set.html_path else {
4914                return StatusCode::NOT_FOUND.into_response();
4915            };
4916            serve_html_artifact(&path, wants_download, &csp_nonce)
4917        }
4918        "pdf" => {
4919            let Some(path) = artifact_set.pdf_path else {
4920                let msg = "PDF report was not generated for this run, or was not recorded in \
4921                           the scan registry. Re-run the analysis with PDF output enabled."
4922                    .to_string();
4923                let html = ErrorTemplate {
4924                    message: msg,
4925                    last_report_url: Some(format!("/runs/html/{run_id}")),
4926                    last_report_label: Some("View HTML Report".to_string()),
4927                    csp_nonce: csp_nonce.clone(),
4928                    version: env!("CARGO_PKG_VERSION"),
4929                }
4930                .render()
4931                .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
4932                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4933            };
4934            // PDF path is recorded but the background task may still be writing it.
4935            // Return a self-refreshing "please wait" page rather than an error.
4936            if !path.exists() {
4937                let html = format!(
4938                    "<!doctype html><html lang=\"en\"><head>\
4939                     <meta charset=utf-8>\
4940                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
4941                     <meta http-equiv=\"refresh\" content=\"5\">\
4942                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
4943                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
4944                     <style nonce=\"{csp_nonce}\">\
4945                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
4946                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
4947                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
4948                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
4949                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
4950                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
4951                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
4952                     background:var(--bg);color:var(--text);}}\
4953                     .top-nav{{position:sticky;top:0;z-index:30;\
4954                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
4955                     border-bottom:1px solid rgba(255,255,255,0.12);\
4956                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
4957                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
4958                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
4959                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
4960                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
4961                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
4962                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
4963                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
4964                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
4965                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
4966                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
4967                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
4968                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
4969                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
4970                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
4971                     justify-content:center;min-height:38px;border-radius:999px;\
4972                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
4973                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
4974                     .theme-toggle .icon-sun{{display:none;}}\
4975                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
4976                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
4977                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
4978                     display:flex;align-items:center;justify-content:center;\
4979                     min-height:calc(100vh - 56px);}}\
4980                     .panel{{background:var(--surface);border:1px solid var(--line);\
4981                     border-radius:var(--radius);box-shadow:var(--shadow);\
4982                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
4983                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
4984                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
4985                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
4986                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
4987                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
4988                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
4989                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
4990                     min-height:42px;padding:0 20px;border-radius:14px;\
4991                     border:1px solid var(--line-strong);text-decoration:none;\
4992                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
4993                     .back-link:hover{{background:var(--line);}}\
4994                     </style></head>\
4995                     <body>\
4996                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
4997                       <a class=\"brand\" href=\"/\">\
4998                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
4999                         <div class=\"brand-copy\">\
5000                           <div class=\"brand-title\">OxideSLOC</div>\
5001                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
5002                         </div>\
5003                       </a>\
5004                       <div class=\"nav-right\">\
5005                         <a class=\"nav-pill\" href=\"/\">Home</a>\
5006                         <div class=\"nav-dropdown\">\
5007                           <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>\
5008                           <div class=\"nav-dropdown-menu\">\
5009                             <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>\
5010                           </div>\
5011                         </div>\
5012                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5013                           <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>\
5014                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5015                           <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>\
5016                         </button>\
5017                       </div>\
5018                     </div></div>\
5019                     <div class=\"page\"><div class=\"panel\">\
5020                       <div class=\"spin-ring\"></div>\
5021                       <h1>Generating PDF\u{2026}</h1>\
5022                       <p>The PDF is being rendered from the HTML report.<br>\
5023                       This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
5024                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5025                     </div></div>\
5026                     <script nonce=\"{csp_nonce}\">\
5027                     (function(){{\
5028                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5029                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
5030                       var t=document.getElementById(\"theme-toggle\");\
5031                       if(t)t.addEventListener(\"click\",function(){{\
5032                         var d=b.classList.toggle(\"dark-theme\");\
5033                         localStorage.setItem(k,d?\"dark\":\"light\");\
5034                       }});\
5035                     }})();\
5036                     </script>\
5037                     </body></html>"
5038                );
5039                return Html(html).into_response();
5040            }
5041            serve_pdf_artifact(
5042                &path,
5043                &artifact_set.report_title,
5044                &run_id,
5045                wants_download,
5046                &csp_nonce,
5047            )
5048        }
5049        "json" => {
5050            let Some(path) = artifact_set.json_path else {
5051                let msg = "JSON result was not generated for this run, or was not recorded in \
5052                           the scan registry. Re-run the analysis with JSON output enabled."
5053                    .to_string();
5054                let html = ErrorTemplate {
5055                    message: msg,
5056                    last_report_url: Some("/view-reports".to_string()),
5057                    last_report_label: Some("View Reports".to_string()),
5058                    csp_nonce: csp_nonce.clone(),
5059                    version: env!("CARGO_PKG_VERSION"),
5060                }
5061                .render()
5062                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
5063                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5064            };
5065            serve_json_artifact(&path, wants_download, &csp_nonce)
5066        }
5067        "csv" => {
5068            let Some(path) = artifact_set.csv_path else {
5069                let msg = "CSV report was not generated for this run, or was not recorded in \
5070                           the scan registry."
5071                    .to_string();
5072                let html = ErrorTemplate {
5073                    message: msg,
5074                    last_report_url: Some(format!("/runs/html/{run_id}")),
5075                    last_report_label: Some("View HTML Report".to_string()),
5076                    csp_nonce: csp_nonce.clone(),
5077                    version: env!("CARGO_PKG_VERSION"),
5078                }
5079                .render()
5080                .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
5081                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5082            };
5083            fs::read(&path).map_or_else(
5084                |_| StatusCode::NOT_FOUND.into_response(),
5085                |bytes| {
5086                    let filename = path.file_name().map_or_else(
5087                        || "report.csv".to_string(),
5088                        |n| n.to_string_lossy().into_owned(),
5089                    );
5090                    (
5091                        [
5092                            (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
5093                            (
5094                                header::CONTENT_DISPOSITION,
5095                                format!("attachment; filename=\"{filename}\""),
5096                            ),
5097                        ],
5098                        bytes,
5099                    )
5100                        .into_response()
5101                },
5102            )
5103        }
5104        "xlsx" => {
5105            let Some(path) = artifact_set.xlsx_path else {
5106                let msg = "Excel report was not generated for this run, or was not recorded in \
5107                           the scan registry."
5108                    .to_string();
5109                let html = ErrorTemplate {
5110                    message: msg,
5111                    last_report_url: Some(format!("/runs/html/{run_id}")),
5112                    last_report_label: Some("View HTML Report".to_string()),
5113                    csp_nonce: csp_nonce.clone(),
5114                    version: env!("CARGO_PKG_VERSION"),
5115                }
5116                .render()
5117                .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
5118                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5119            };
5120            fs::read(&path).map_or_else(
5121                |_| StatusCode::NOT_FOUND.into_response(),
5122                |bytes| {
5123                    let filename = path.file_name().map_or_else(
5124                        || "report.xlsx".to_string(),
5125                        |n| n.to_string_lossy().into_owned(),
5126                    );
5127                    (
5128                        [
5129                            (
5130                                header::CONTENT_TYPE,
5131                                "application/vnd.openxmlformats-officedocument\
5132                                 .spreadsheetml.sheet"
5133                                    .to_string(),
5134                            ),
5135                            (
5136                                header::CONTENT_DISPOSITION,
5137                                format!("attachment; filename=\"{filename}\""),
5138                            ),
5139                        ],
5140                        bytes,
5141                    )
5142                        .into_response()
5143                },
5144            )
5145        }
5146        "scan-config" => {
5147            let path = artifact_set
5148                .scan_config_path
5149                .as_deref()
5150                .map(std::path::Path::to_path_buf)
5151                .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
5152                .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
5153            fs::read(&path).map_or_else(
5154                |_| StatusCode::NOT_FOUND.into_response(),
5155                |bytes| {
5156                    (
5157                        [
5158                            (
5159                                header::CONTENT_TYPE,
5160                                "application/json; charset=utf-8".to_string(),
5161                            ),
5162                            (
5163                                header::CONTENT_DISPOSITION,
5164                                "attachment; filename=\"scan-config.json\"".to_string(),
5165                            ),
5166                        ],
5167                        bytes,
5168                    )
5169                        .into_response()
5170                },
5171            )
5172        }
5173        _ if artifact.starts_with("sub_") => {
5174            if artifact.len() > 128
5175                || !artifact
5176                    .chars()
5177                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
5178            {
5179                return StatusCode::BAD_REQUEST.into_response();
5180            }
5181            let filename = format!("{artifact}.html");
5182            let path = artifact_set.output_dir.join(&filename);
5183            if !path.exists() {
5184                let html = ErrorTemplate {
5185                    message: format!(
5186                        "Sub-report '{artifact}' was not found in the run directory.\n\
5187                         Re-run the analysis with 'Detect and separate git submodules' \
5188                         and HTML output enabled."
5189                    ),
5190                    last_report_url: Some("/view-reports".to_string()),
5191                    last_report_label: Some("View Reports".to_string()),
5192                    csp_nonce: csp_nonce.clone(),
5193                    version: env!("CARGO_PKG_VERSION"),
5194                }
5195                .render()
5196                .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
5197                return (StatusCode::NOT_FOUND, Html(html)).into_response();
5198            }
5199            serve_html_artifact(&path, wants_download, &csp_nonce)
5200        }
5201        _ => StatusCode::NOT_FOUND.into_response(),
5202    }
5203}
5204
5205// ── History ───────────────────────────────────────────────────────────────────
5206
5207struct SubmoduleLinkRow {
5208    name: String,
5209    url: String,
5210}
5211
5212struct HistoryEntryRow {
5213    run_id: String,
5214    run_id_short: String,
5215    timestamp: String,
5216    timestamp_utc_ms: i64,
5217    project_label: String,
5218    project_path: String,
5219    files_analyzed: u64,
5220    files_skipped: u64,
5221    code_lines: u64,
5222    comment_lines: u64,
5223    blank_lines: u64,
5224    git_branch: String,
5225    git_commit: String,
5226    has_html: bool,
5227    has_json: bool,
5228    has_pdf: bool,
5229    submodule_links: Vec<SubmoduleLinkRow>,
5230    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
5231    submodule_names_csv: String,
5232}
5233
5234/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
5235fn nth_weekday_of_month(
5236    year: i32,
5237    month: u32,
5238    weekday: chrono::Weekday,
5239    n: u32,
5240) -> chrono::NaiveDate {
5241    use chrono::Datelike;
5242    let mut count = 0u32;
5243    let mut day = 1u32;
5244    loop {
5245        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
5246        if d.weekday() == weekday {
5247            count += 1;
5248            if count == n {
5249                return d;
5250            }
5251        }
5252        day += 1;
5253    }
5254}
5255
5256/// Returns true if `dt` falls within US Pacific Daylight Time.
5257/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
5258/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
5259fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
5260    use chrono::{Datelike, TimeZone};
5261    let year = dt.year();
5262    let dst_start = chrono::Utc.from_utc_datetime(
5263        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
5264            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
5265    );
5266    let dst_end = chrono::Utc.from_utc_datetime(
5267        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
5268            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
5269    );
5270    dt >= dst_start && dt < dst_end
5271}
5272
5273fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
5274    if is_pacific_dst(dt) {
5275        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
5276            .format("%Y-%m-%d %H:%M PDT")
5277            .to_string()
5278    } else {
5279        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
5280            .format("%Y-%m-%d %H:%M PST")
5281            .to_string()
5282    }
5283}
5284
5285/// Format a timestamp for the result-page meta row, matching the seconds-precision
5286/// style used in the saved HTML report.  When `parens` is true the timezone label
5287/// is wrapped in parentheses (used for the "Generated" chip).
5288fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>, parens: bool) -> String {
5289    let (offset, tz) = if is_pacific_dst(dt) {
5290        (
5291            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
5292            "PDT",
5293        )
5294    } else {
5295        (
5296            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
5297            "PST",
5298        )
5299    };
5300    let t = dt
5301        .with_timezone(&offset)
5302        .format("%Y-%m-%d %H:%M:%S")
5303        .to_string();
5304    if parens {
5305        format!("{t} ({tz})")
5306    } else {
5307        format!("{t} {tz}")
5308    }
5309}
5310
5311fn fmt_git_date(iso: &str) -> Option<String> {
5312    chrono::DateTime::parse_from_rfc3339(iso)
5313        .ok()
5314        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
5315}
5316
5317fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
5318    reg.entries
5319        .iter()
5320        .map(|e| {
5321            let submodule_links = {
5322                let mut links: Vec<SubmoduleLinkRow> = vec![];
5323                let sub_dir = e
5324                    .html_path
5325                    .as_ref()
5326                    .and_then(|p| p.parent())
5327                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5328                if let Some(dir) = sub_dir {
5329                    if let Ok(rd) = std::fs::read_dir(dir) {
5330                        for entry_res in rd.flatten() {
5331                            let fname = entry_res.file_name();
5332                            let fname_str = fname.to_string_lossy();
5333                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5334                                let stem = &fname_str[..fname_str.len() - 5];
5335                                let display = stem[4..].replace('-', " ");
5336                                links.push(SubmoduleLinkRow {
5337                                    name: display,
5338                                    url: format!("/runs/{stem}/{}", e.run_id),
5339                                });
5340                            }
5341                        }
5342                    }
5343                }
5344                links.sort_by(|a, b| a.name.cmp(&b.name));
5345                links
5346            };
5347            let submodule_names_csv = submodule_links
5348                .iter()
5349                .map(|l| l.name.as_str())
5350                .collect::<Vec<_>>()
5351                .join(",");
5352            HistoryEntryRow {
5353                run_id: e.run_id.clone(),
5354                run_id_short: e
5355                    .run_id
5356                    .split('-')
5357                    .next_back()
5358                    .unwrap_or(&e.run_id)
5359                    .chars()
5360                    .take(7)
5361                    .collect(),
5362                timestamp: fmt_la_time(e.timestamp_utc),
5363                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
5364                project_label: e.project_label.clone(),
5365                project_path: e
5366                    .input_roots
5367                    .first()
5368                    .map(|s| sanitize_path_str(s))
5369                    .unwrap_or_default(),
5370                files_analyzed: e.summary.files_analyzed,
5371                files_skipped: e.summary.files_skipped,
5372                code_lines: e.summary.code_lines,
5373                comment_lines: e.summary.comment_lines,
5374                blank_lines: e.summary.blank_lines,
5375                git_branch: e.git_branch.clone().unwrap_or_default(),
5376                git_commit: e.git_commit.clone().unwrap_or_default(),
5377                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
5378                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
5379                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
5380                submodule_links,
5381                submodule_names_csv,
5382            }
5383        })
5384        .collect()
5385}
5386
5387#[derive(Deserialize, Default)]
5388struct HistoryQuery {
5389    linked: Option<String>,
5390    error: Option<String>,
5391}
5392
5393async fn history_handler(
5394    State(state): State<AppState>,
5395    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5396    Query(query): Query<HistoryQuery>,
5397) -> impl IntoResponse {
5398    // Auto-scan all watched directories before rendering so the list stays fresh.
5399    auto_scan_watched_dirs(&state).await;
5400    let watched_dirs: Vec<String> = {
5401        let wd = state.watched_dirs.lock().await;
5402        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5403    };
5404    let mut entries = {
5405        let reg = state.registry.lock().await;
5406        make_history_rows(&reg)
5407    };
5408    entries.retain(|e| e.has_html);
5409    let total_scans = entries.len();
5410    let linked_count = query
5411        .linked
5412        .as_deref()
5413        .and_then(|s| s.parse::<usize>().ok())
5414        .unwrap_or(0);
5415    let browse_error = query.error.filter(|s| !s.is_empty());
5416    let template = HistoryTemplate {
5417        version: env!("CARGO_PKG_VERSION"),
5418        entries,
5419        total_scans,
5420        linked_count,
5421        browse_error,
5422        watched_dirs,
5423        csp_nonce,
5424        server_mode: state.server_mode,
5425    };
5426    Html(
5427        template
5428            .render()
5429            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5430    )
5431    .into_response()
5432}
5433
5434async fn compare_select_handler(
5435    State(state): State<AppState>,
5436    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5437) -> impl IntoResponse {
5438    auto_scan_watched_dirs(&state).await;
5439    let watched_dirs: Vec<String> = {
5440        let wd = state.watched_dirs.lock().await;
5441        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5442    };
5443    let mut entries = {
5444        let reg = state.registry.lock().await;
5445        make_history_rows(&reg)
5446    };
5447    entries.retain(|e| e.has_json);
5448    let total_scans = entries.len();
5449    let template = CompareSelectTemplate {
5450        version: env!("CARGO_PKG_VERSION"),
5451        entries,
5452        total_scans,
5453        watched_dirs,
5454        csp_nonce,
5455        server_mode: state.server_mode,
5456    };
5457    Html(
5458        template
5459            .render()
5460            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5461    )
5462    .into_response()
5463}
5464
5465// ── Compare ───────────────────────────────────────────────────────────────────
5466
5467#[derive(Deserialize, Default)]
5468struct CompareQuery {
5469    a: Option<String>,
5470    b: Option<String>,
5471    /// Optional submodule name to scope the comparison to one submodule.
5472    sub: Option<String>,
5473    /// "super" to exclude all submodule files and show only the super-repo.
5474    scope: Option<String>,
5475}
5476
5477struct CompareFileDeltaRow {
5478    relative_path: String,
5479    language: String,
5480    status: String,
5481    baseline_code: i64,
5482    current_code: i64,
5483    code_delta_str: String,
5484    code_delta_class: String,
5485    comment_delta_str: String,
5486    comment_delta_class: String,
5487    total_delta_str: String,
5488    total_delta_class: String,
5489}
5490
5491/// Recompute `summary_totals` from the current `per_file_records` slice.
5492/// Used when `per_file_records` has been narrowed to a submodule subset.
5493fn recompute_summary_from_records(run: &mut AnalysisRun) {
5494    let files_analyzed = run
5495        .per_file_records
5496        .iter()
5497        .filter(|r| r.language.is_some())
5498        .count() as u64;
5499    let code_lines: u64 = run
5500        .per_file_records
5501        .iter()
5502        .map(|r| r.effective_counts.code_lines)
5503        .sum();
5504    let comment_lines: u64 = run
5505        .per_file_records
5506        .iter()
5507        .map(|r| r.effective_counts.comment_lines)
5508        .sum();
5509    let blank_lines: u64 = run
5510        .per_file_records
5511        .iter()
5512        .map(|r| r.effective_counts.blank_lines)
5513        .sum();
5514    run.summary_totals.files_analyzed = files_analyzed;
5515    run.summary_totals.files_considered = files_analyzed;
5516    run.summary_totals.code_lines = code_lines;
5517    run.summary_totals.comment_lines = comment_lines;
5518    run.summary_totals.blank_lines = blank_lines;
5519    run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
5520}
5521
5522fn fmt_delta(n: i64) -> String {
5523    if n > 0 {
5524        format!("+{n}")
5525    } else {
5526        format!("{n}")
5527    }
5528}
5529
5530fn delta_class(n: i64) -> &'static str {
5531    use std::cmp::Ordering;
5532    match n.cmp(&0) {
5533        Ordering::Greater => "pos",
5534        Ordering::Less => "neg",
5535        Ordering::Equal => "zero",
5536    }
5537}
5538
5539// ratio/percentage display, precision loss acceptable
5540#[allow(clippy::cast_precision_loss)]
5541fn fmt_pct(delta: i64, baseline: u64) -> String {
5542    if baseline == 0 {
5543        return "—".to_string();
5544    }
5545    #[allow(clippy::cast_precision_loss)]
5546    let pct = (delta as f64 / baseline as f64) * 100.0;
5547    if pct > 0.049 {
5548        format!("+{pct:.1}%")
5549    } else if pct < -0.049 {
5550        format!("{pct:.1}%")
5551    } else {
5552        "±0%".to_string()
5553    }
5554}
5555
5556/// Returns (`display_string`, `css_class`) for a numeric change column cell.
5557fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
5558    prev.map_or_else(
5559        || ("—".to_string(), "na"),
5560        |p| {
5561            #[allow(clippy::cast_possible_wrap)]
5562            let d = curr as i64 - p as i64;
5563            (fmt_delta(d), delta_class(d))
5564        },
5565    )
5566}
5567
5568#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
5569fn load_scan_for_compare(
5570    json_path: &std::path::Path,
5571    scan_label: &str,
5572    run_id: &str,
5573    server_mode: bool,
5574    compare_url: &str,
5575    csp_nonce: &str,
5576) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
5577    match read_json(json_path) {
5578        Ok(r) => Ok(r),
5579        Err(e) => {
5580            if server_mode {
5581                let html = ErrorTemplate {
5582                    message: format!(
5583                        "Could not load {scan_label} scan data. The scan output folder may have \
5584                         been moved, renamed, or deleted. Re-running the analysis will create \
5585                         fresh comparison data."
5586                    ),
5587                    last_report_url: Some("/compare-scans".to_string()),
5588                    last_report_label: Some("Compare Scans".to_string()),
5589                    csp_nonce: csp_nonce.to_owned(),
5590                    version: env!("CARGO_PKG_VERSION"),
5591                }
5592                .render()
5593                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
5594                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5595            }
5596            let msg = format!(
5597                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
5598                json_path.display()
5599            );
5600            let folder_hint = json_path
5601                .parent()
5602                .map(|p| p.display().to_string())
5603                .unwrap_or_default();
5604            Err(missing_scan_relocate_response(
5605                &msg,
5606                run_id,
5607                &folder_hint,
5608                compare_url,
5609                false,
5610                csp_nonce,
5611            ))
5612        }
5613    }
5614}
5615
5616struct ChurnStats {
5617    new_scope: bool,
5618    scope_flag: bool,
5619    churn_rate_str: String,
5620    churn_rate_class: String,
5621}
5622
5623fn compute_churn_stats(
5624    baseline_code: u64,
5625    current_code: u64,
5626    lines_added: i64,
5627    lines_removed: i64,
5628) -> ChurnStats {
5629    let new_scope = baseline_code == 0 && current_code > 0;
5630    #[allow(clippy::cast_precision_loss)]
5631    let churn_pct = if baseline_code > 0 {
5632        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
5633    } else {
5634        0.0
5635    };
5636    #[allow(clippy::cast_precision_loss)]
5637    let scope_flag =
5638        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
5639    let churn_rate_str = if new_scope {
5640        "New".to_string()
5641    } else if baseline_code > 0 {
5642        format!("{churn_pct:.1}%")
5643    } else {
5644        "—".to_string()
5645    };
5646    let churn_rate_class = if new_scope || churn_pct > 20.0 {
5647        "high".to_string()
5648    } else if churn_pct > 5.0 {
5649        "med".to_string()
5650    } else {
5651        "low".to_string()
5652    };
5653    ChurnStats {
5654        new_scope,
5655        scope_flag,
5656        churn_rate_str,
5657        churn_rate_class,
5658    }
5659}
5660
5661/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
5662/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
5663/// variables to the large CompareTemplate, which causes rustc stack overflows on Windows.
5664fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
5665    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
5666    if !has_data {
5667        return String::new();
5668    }
5669    let base_str = s
5670        .baseline_coverage_line_pct
5671        .map(|p| format!("{p:.1}%"))
5672        .unwrap_or_else(|| "\u{2014}".into());
5673    let curr_str = s
5674        .current_coverage_line_pct
5675        .map(|p| format!("{p:.1}%"))
5676        .unwrap_or_else(|| "\u{2014}".into());
5677    let (delta_str, cls) = match s.coverage_line_pct_delta {
5678        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
5679        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
5680        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
5681        None => ("\u{2014}".into(), "zero"),
5682    };
5683    format!(
5684        r#"<div class="delta-card">
5685          <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>
5686          <div class="delta-card-label">Line coverage</div>
5687          <div class="delta-card-from">Before: {base_str}</div>
5688          <div class="delta-card-to">{curr_str}</div>
5689          <span class="delta-card-change {cls}">{delta_str}</span>
5690        </div>"#
5691    )
5692}
5693
5694#[allow(clippy::too_many_lines)]
5695async fn compare_handler(
5696    State(state): State<AppState>,
5697    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5698    Query(query): Query<CompareQuery>,
5699) -> impl IntoResponse {
5700    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
5701    // redirect to the history page where the user can select two runs.
5702    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
5703        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
5704        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
5705    };
5706
5707    let (maybe_a, maybe_b) = {
5708        let reg = state.registry.lock().await;
5709        (
5710            reg.find_by_run_id(&run_id_a).cloned(),
5711            reg.find_by_run_id(&run_id_b).cloned(),
5712        )
5713    };
5714
5715    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
5716        let html = ErrorTemplate {
5717            message: "One or both run IDs were not found in scan history. \
5718                      The runs may have been deleted or the registry may have been reset."
5719                .to_string(),
5720            last_report_url: Some("/compare-scans".to_string()),
5721            last_report_label: Some("Compare Scans".to_string()),
5722            csp_nonce: csp_nonce.clone(),
5723            version: env!("CARGO_PKG_VERSION"),
5724        }
5725        .render()
5726        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
5727        return Html(html).into_response();
5728    };
5729
5730    // Ensure older scan is always the baseline.
5731    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
5732        (entry_a, entry_b)
5733    } else {
5734        (entry_b, entry_a)
5735    };
5736
5737    // If query params were in the wrong order, redirect to canonical URL so the
5738    // browser always shows the same URL for the same two scans regardless of how
5739    // the user arrived here (Full diff button vs. Compare Scans selection).
5740    if baseline_entry.run_id != run_id_a {
5741        let canonical = format!(
5742            "/compare?a={}&b={}",
5743            baseline_entry.run_id, current_entry.run_id
5744        );
5745        return axum::response::Redirect::to(&canonical).into_response();
5746    }
5747
5748    let (Some(base_json), Some(curr_json)) = (
5749        baseline_entry.json_path.as_ref(),
5750        current_entry.json_path.as_ref(),
5751    ) else {
5752        let html = ErrorTemplate {
5753            message: "Full comparison requires JSON scan data, which was not saved for one or \
5754                      both of these runs. JSON is now always saved for new scans — re-run the \
5755                      affected projects to enable comparisons."
5756                .to_string(),
5757            last_report_url: Some("/compare-scans".to_string()),
5758            last_report_label: Some("Compare Scans".to_string()),
5759            csp_nonce: csp_nonce.clone(),
5760            version: env!("CARGO_PKG_VERSION"),
5761        }
5762        .render()
5763        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
5764        return Html(html).into_response();
5765    };
5766
5767    let compare_url = format!(
5768        "/compare?a={}&b={}",
5769        baseline_entry.run_id, current_entry.run_id
5770    );
5771
5772    let baseline_run = match load_scan_for_compare(
5773        base_json,
5774        "baseline",
5775        &baseline_entry.run_id,
5776        state.server_mode,
5777        &compare_url,
5778        &csp_nonce,
5779    ) {
5780        Ok(r) => r,
5781        Err(resp) => return resp,
5782    };
5783    let current_run = match load_scan_for_compare(
5784        curr_json,
5785        "current",
5786        &current_entry.run_id,
5787        state.server_mode,
5788        &compare_url,
5789        &csp_nonce,
5790    ) {
5791        Ok(r) => r,
5792        Err(resp) => return resp,
5793    };
5794
5795    let active_submodule = query.sub.clone();
5796    let super_scope_active = query.scope.as_deref() == Some("super");
5797
5798    let submodule_options = baseline_run
5799        .submodule_summaries
5800        .iter()
5801        .chain(current_run.submodule_summaries.iter())
5802        .map(|s| s.name.clone())
5803        .collect::<std::collections::BTreeSet<_>>()
5804        .into_iter()
5805        .collect::<Vec<_>>();
5806    let has_any_submodule_data = !submodule_options.is_empty();
5807
5808    // Narrow per_file_records when a scope is active, then recompute totals.
5809    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
5810        let mut b = baseline_run;
5811        let mut c = current_run;
5812        b.per_file_records
5813            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
5814        c.per_file_records
5815            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
5816        recompute_summary_from_records(&mut b);
5817        recompute_summary_from_records(&mut c);
5818        (b, c)
5819    } else if super_scope_active {
5820        let mut b = baseline_run;
5821        let mut c = current_run;
5822        b.per_file_records.retain(|f| f.submodule.is_none());
5823        c.per_file_records.retain(|f| f.submodule.is_none());
5824        recompute_summary_from_records(&mut b);
5825        recompute_summary_from_records(&mut c);
5826        (b, c)
5827    } else {
5828        (baseline_run, current_run)
5829    };
5830
5831    let comparison = compute_delta(&effective_baseline, &effective_current);
5832
5833    let file_rows: Vec<CompareFileDeltaRow> = comparison
5834        .file_deltas
5835        .iter()
5836        .map(|d| CompareFileDeltaRow {
5837            relative_path: d.relative_path.clone(),
5838            language: d.language.clone().unwrap_or_else(|| "—".into()),
5839            status: match d.status {
5840                FileChangeStatus::Added => "added".into(),
5841                FileChangeStatus::Removed => "removed".into(),
5842                FileChangeStatus::Modified => "modified".into(),
5843                FileChangeStatus::Unchanged => "unchanged".into(),
5844            },
5845            baseline_code: d.baseline_code,
5846            current_code: d.current_code,
5847            code_delta_str: fmt_delta(d.code_delta),
5848            code_delta_class: delta_class(d.code_delta).into(),
5849            comment_delta_str: fmt_delta(d.comment_delta),
5850            comment_delta_class: delta_class(d.comment_delta).into(),
5851            total_delta_str: fmt_delta(d.total_delta),
5852            total_delta_class: delta_class(d.total_delta).into(),
5853        })
5854        .collect();
5855
5856    let project_path = baseline_entry
5857        .input_roots
5858        .first()
5859        .map(|s| sanitize_path_str(s))
5860        .unwrap_or_default();
5861    let lines_added = sum_added_code_lines(&comparison);
5862    let lines_removed = sum_removed_code_lines(&comparison);
5863    let churn = compute_churn_stats(
5864        comparison.summary.baseline_code,
5865        comparison.summary.current_code,
5866        lines_added,
5867        lines_removed,
5868    );
5869    let s = &comparison.summary;
5870    let template = CompareTemplate {
5871        version: env!("CARGO_PKG_VERSION"),
5872        project_label: baseline_entry.project_label.clone(),
5873        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
5874        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
5875        baseline_run_id: baseline_entry.run_id.clone(),
5876        current_run_id: current_entry.run_id.clone(),
5877        baseline_run_id_short: baseline_entry
5878            .run_id
5879            .split('-')
5880            .next_back()
5881            .unwrap_or(&baseline_entry.run_id)
5882            .chars()
5883            .take(7)
5884            .collect(),
5885        current_run_id_short: current_entry
5886            .run_id
5887            .split('-')
5888            .next_back()
5889            .unwrap_or(&current_entry.run_id)
5890            .chars()
5891            .take(7)
5892            .collect(),
5893        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
5894        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
5895        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
5896        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
5897        project_path: project_path.clone(),
5898        baseline_code: s.baseline_code,
5899        current_code: s.current_code,
5900        code_lines_delta_str: fmt_delta(s.code_lines_delta),
5901        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
5902        baseline_files: s.baseline_files,
5903        current_files: s.current_files,
5904        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
5905        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
5906        baseline_comments: s.baseline_comments,
5907        current_comments: s.current_comments,
5908        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
5909        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
5910        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
5911        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
5912        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
5913        code_lines_added: lines_added,
5914        code_lines_removed: lines_removed,
5915        new_scope: churn.new_scope,
5916        churn_rate_str: churn.churn_rate_str,
5917        churn_rate_class: churn.churn_rate_class,
5918        scope_flag: churn.scope_flag,
5919        files_added: comparison.files_added,
5920        files_removed: comparison.files_removed,
5921        files_modified: comparison.files_modified,
5922        files_unchanged: comparison.files_unchanged,
5923        file_rows,
5924        baseline_git_author: baseline_entry.git_author.clone(),
5925        current_git_author: current_entry.git_author.clone(),
5926        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
5927        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
5928        baseline_git_tags: baseline_entry.git_tags.clone(),
5929        current_git_tags: current_entry.git_tags.clone(),
5930        baseline_git_commit_date: baseline_entry
5931            .git_commit_date
5932            .as_deref()
5933            .and_then(fmt_git_date),
5934        current_git_commit_date: current_entry
5935            .git_commit_date
5936            .as_deref()
5937            .and_then(fmt_git_date),
5938        project_name: project_path
5939            .rsplit(['/', '\\'])
5940            .find(|s| !s.is_empty())
5941            .unwrap_or(&project_path)
5942            .to_string(),
5943        submodule_options,
5944        has_any_submodule_data,
5945        active_submodule,
5946        super_scope_active,
5947        csp_nonce,
5948        coverage_delta_card: build_coverage_delta_card(s),
5949    };
5950
5951    Html(
5952        template
5953            .render()
5954            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
5955    )
5956    .into_response()
5957}
5958
5959// ── Badge endpoint ────────────────────────────────────────────────────────────
5960// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
5961// pages, Jira descriptions, etc.
5962//
5963// GET /badge/<metric>?label=<override>&color=<hex>
5964// Metrics: code-lines  files  comment-lines  blank-lines
5965
5966fn format_number(n: u64) -> String {
5967    let s = n.to_string();
5968    let mut out = String::with_capacity(s.len() + s.len() / 3);
5969    let len = s.len();
5970    for (i, c) in s.chars().enumerate() {
5971        if i > 0 && (len - i).is_multiple_of(3) {
5972            out.push(',');
5973        }
5974        out.push(c);
5975    }
5976    out
5977}
5978
5979const fn badge_char_width(c: char) -> f64 {
5980    match c {
5981        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
5982        'm' | 'w' => 9.0,
5983        ' ' => 4.0,
5984        _ => 6.5,
5985    }
5986}
5987
5988#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5989fn badge_text_px(text: &str) -> u32 {
5990    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
5991}
5992
5993fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
5994    let lw = badge_text_px(label) + 20;
5995    let rw = badge_text_px(value) + 20;
5996    let total = lw + rw;
5997    let lx = lw / 2;
5998    let rx = lw + rw / 2;
5999    let le = escape_html(label);
6000    let ve = escape_html(value);
6001    let ce = escape_html(color);
6002    format!(
6003        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
6004  <rect width="{total}" height="20" fill="#555"/>
6005  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
6006  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
6007    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
6008    <text x="{lx}" y="13">{le}</text>
6009    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
6010    <text x="{rx}" y="13">{ve}</text>
6011  </g>
6012</svg>"##
6013    )
6014}
6015
6016#[derive(Deserialize)]
6017struct BadgeQuery {
6018    label: Option<String>,
6019    color: Option<String>,
6020}
6021
6022async fn badge_handler(
6023    State(state): State<AppState>,
6024    AxumPath(metric): AxumPath<String>,
6025    Query(query): Query<BadgeQuery>,
6026) -> Response {
6027    let entry = {
6028        let reg = state.registry.lock().await;
6029        reg.entries.first().cloned()
6030    };
6031
6032    let Some(entry) = entry else {
6033        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
6034        return (
6035            [
6036                (header::CONTENT_TYPE, "image/svg+xml"),
6037                (header::CACHE_CONTROL, "no-cache, max-age=0"),
6038            ],
6039            svg,
6040        )
6041            .into_response();
6042    };
6043
6044    let (default_label, value, default_color) = match metric.as_str() {
6045        "code-lines" => (
6046            "code lines",
6047            format_number(entry.summary.code_lines),
6048            "#4a78ee",
6049        ),
6050        "files" => (
6051            "files analyzed",
6052            format_number(entry.summary.files_analyzed),
6053            "#4a9862",
6054        ),
6055        "comment-lines" => (
6056            "comment lines",
6057            format_number(entry.summary.comment_lines),
6058            "#b35428",
6059        ),
6060        "blank-lines" => (
6061            "blank lines",
6062            format_number(entry.summary.blank_lines),
6063            "#7a5db0",
6064        ),
6065        _ => return StatusCode::NOT_FOUND.into_response(),
6066    };
6067
6068    let label = query.label.as_deref().unwrap_or(default_label);
6069    let color = query.color.as_deref().unwrap_or(default_color);
6070    let svg = render_badge_svg(label, &value, color);
6071
6072    (
6073        [
6074            (header::CONTENT_TYPE, "image/svg+xml"),
6075            (header::CACHE_CONTROL, "no-cache, max-age=0"),
6076        ],
6077        svg,
6078    )
6079        .into_response()
6080}
6081
6082// ── Metrics API ───────────────────────────────────────────────────────────────
6083// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
6084// Confluence automation, Jira webhooks, etc.
6085//
6086// GET /api/metrics/latest
6087// GET /api/metrics/<run_id>
6088
6089#[derive(Serialize)]
6090struct ApiCoverageBlock {
6091    lines_found: u64,
6092    lines_hit: u64,
6093    line_pct: f64,
6094    functions_found: u64,
6095    functions_hit: u64,
6096    function_pct: f64,
6097    branches_found: u64,
6098    branches_hit: u64,
6099    branch_pct: f64,
6100}
6101
6102#[derive(Serialize)]
6103struct ApiMetricsResponse {
6104    run_id: String,
6105    timestamp: String,
6106    project: String,
6107    summary: ApiSummaryPayload,
6108    languages: Vec<ApiLanguageRow>,
6109    #[serde(skip_serializing_if = "Option::is_none")]
6110    coverage: Option<ApiCoverageBlock>,
6111}
6112
6113#[derive(Serialize)]
6114struct ApiSummaryPayload {
6115    files_analyzed: u64,
6116    files_skipped: u64,
6117    code_lines: u64,
6118    comment_lines: u64,
6119    blank_lines: u64,
6120    total_physical_lines: u64,
6121    functions: u64,
6122    classes: u64,
6123    variables: u64,
6124    imports: u64,
6125}
6126
6127#[derive(Serialize)]
6128struct ApiLanguageRow {
6129    name: String,
6130    files: u64,
6131    code_lines: u64,
6132    comment_lines: u64,
6133    blank_lines: u64,
6134    functions: u64,
6135    classes: u64,
6136    variables: u64,
6137    imports: u64,
6138}
6139
6140async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
6141    let entry = {
6142        let reg = state.registry.lock().await;
6143        reg.entries.first().cloned()
6144    };
6145    entry.map_or_else(
6146        || error::not_found("no scans recorded yet"),
6147        |e| build_metrics_response(&e),
6148    )
6149}
6150
6151async fn api_metrics_run_handler(
6152    State(state): State<AppState>,
6153    AxumPath(run_id): AxumPath<String>,
6154) -> Response {
6155    let entry = {
6156        let reg = state.registry.lock().await;
6157        reg.find_by_run_id(&run_id).cloned()
6158    };
6159    entry.map_or_else(
6160        || error::not_found("run not found"),
6161        |e| build_metrics_response(&e),
6162    )
6163}
6164
6165fn build_metrics_response(entry: &RegistryEntry) -> Response {
6166    let languages: Vec<ApiLanguageRow> = entry
6167        .json_path
6168        .as_ref()
6169        .and_then(|p| read_json(p).ok())
6170        .map(|run| {
6171            run.totals_by_language
6172                .iter()
6173                .map(|l| ApiLanguageRow {
6174                    name: l.language.display_name().to_string(),
6175                    files: l.files,
6176                    code_lines: l.code_lines,
6177                    comment_lines: l.comment_lines,
6178                    blank_lines: l.blank_lines,
6179                    functions: l.functions,
6180                    classes: l.classes,
6181                    variables: l.variables,
6182                    imports: l.imports,
6183                })
6184                .collect()
6185        })
6186        .unwrap_or_default();
6187
6188    let s = &entry.summary;
6189    let coverage = if s.coverage_lines_found > 0 {
6190        let pct = |hit: u64, found: u64| -> f64 {
6191            if found == 0 {
6192                0.0
6193            } else {
6194                #[allow(clippy::cast_precision_loss)]
6195                let v = (hit as f64 / found as f64) * 100.0;
6196                (v * 10.0).round() / 10.0
6197            }
6198        };
6199        Some(ApiCoverageBlock {
6200            lines_found: s.coverage_lines_found,
6201            lines_hit: s.coverage_lines_hit,
6202            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
6203            functions_found: s.coverage_functions_found,
6204            functions_hit: s.coverage_functions_hit,
6205            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
6206            branches_found: s.coverage_branches_found,
6207            branches_hit: s.coverage_branches_hit,
6208            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
6209        })
6210    } else {
6211        None
6212    };
6213    Json(ApiMetricsResponse {
6214        run_id: entry.run_id.clone(),
6215        timestamp: entry.timestamp_utc.to_rfc3339(),
6216        project: entry.project_label.clone(),
6217        summary: ApiSummaryPayload {
6218            files_analyzed: s.files_analyzed,
6219            files_skipped: s.files_skipped,
6220            code_lines: s.code_lines,
6221            comment_lines: s.comment_lines,
6222            blank_lines: s.blank_lines,
6223            total_physical_lines: s.total_physical_lines,
6224            functions: s.functions,
6225            classes: s.classes,
6226            variables: s.variables,
6227            imports: s.imports,
6228        },
6229        languages,
6230        coverage,
6231    })
6232    .into_response()
6233}
6234
6235// ── Project history API ───────────────────────────────────────────────────────
6236// Protected. Called by the wizard JS when the project path changes, so the UI
6237// can show a "scanned N times before" badge without a full page reload.
6238//
6239// GET /api/project-history?path=<project_root>
6240
6241#[derive(Deserialize)]
6242struct ProjectHistoryQuery {
6243    path: Option<String>,
6244}
6245
6246#[derive(Serialize)]
6247struct ProjectHistoryResponse {
6248    scan_count: usize,
6249    last_scan_id: Option<String>,
6250    last_scan_timestamp: Option<String>,
6251    last_scan_code_lines: Option<u64>,
6252    last_git_branch: Option<String>,
6253    last_git_commit: Option<String>,
6254}
6255
6256/// Return true if `entry` matches either an exact root path or an upload-staging
6257/// path with the same project name (needed because each upload gets a fresh UUID dir).
6258fn entry_matches_project(
6259    entry: &RegistryEntry,
6260    root_str: &str,
6261    upload_root: &str,
6262    upload_name_suffix: Option<&str>,
6263) -> bool {
6264    if entry.input_roots.iter().any(|r| r == root_str) {
6265        return true;
6266    }
6267    if let Some(suffix) = upload_name_suffix {
6268        return entry
6269            .input_roots
6270            .iter()
6271            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
6272    }
6273    false
6274}
6275
6276async fn project_history_handler(
6277    State(state): State<AppState>,
6278    Query(query): Query<ProjectHistoryQuery>,
6279) -> Response {
6280    let path = query.path.unwrap_or_default();
6281    let resolved = resolve_input_path(&path);
6282    let root_str = resolved.to_string_lossy().replace('\\', "/");
6283
6284    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
6285    // The UUID is freshly generated for every upload, so an exact root_str match never finds
6286    // previous scans of the same project. Fall back to matching by project name within the
6287    // uploads staging directory so Scan History populates correctly across uploads.
6288    let upload_root = std::env::temp_dir()
6289        .join("oxide-sloc-uploads")
6290        .to_string_lossy()
6291        .replace('\\', "/");
6292    let upload_name_suffix: Option<String> =
6293        if state.server_mode && root_str.starts_with(&upload_root) {
6294            resolved
6295                .file_name()
6296                .and_then(|n| n.to_str())
6297                .map(|name| format!("/{name}"))
6298        } else {
6299            None
6300        };
6301    let suffix_ref = upload_name_suffix.as_deref();
6302
6303    let entries: Vec<_> = {
6304        let reg = state.registry.lock().await;
6305        reg.entries
6306            .iter()
6307            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
6308            .cloned()
6309            .collect()
6310    };
6311    let scan_count = entries.len();
6312    let last = entries.first();
6313    let last_scan_id = last.map(|e| e.run_id.clone());
6314    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
6315    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
6316    let last_git_branch = last.and_then(|e| e.git_branch.clone());
6317    let last_git_commit = last.and_then(|e| e.git_commit.clone());
6318
6319    Json(ProjectHistoryResponse {
6320        scan_count,
6321        last_scan_id,
6322        last_scan_timestamp,
6323        last_scan_code_lines,
6324        last_git_branch,
6325        last_git_commit,
6326    })
6327    .into_response()
6328}
6329
6330// ── Metrics history API ───────────────────────────────────────────────────────
6331// Protected. Returns a JSON array of lightweight scan snapshots for plotting
6332// trend charts.
6333//
6334// GET /api/metrics/history?root=<path>&limit=<n>
6335
6336#[derive(Deserialize)]
6337struct MetricsHistoryQuery {
6338    root: Option<String>,
6339    limit: Option<usize>,
6340    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
6341    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
6342    submodule: Option<String>,
6343}
6344
6345#[derive(Serialize)]
6346struct MetricsSubmoduleLink {
6347    name: String,
6348    url: String,
6349}
6350
6351#[derive(Serialize)]
6352struct MetricsHistoryEntry {
6353    run_id: String,
6354    run_id_short: String,
6355    timestamp: String,
6356    commit: Option<String>,
6357    branch: Option<String>,
6358    tags: Vec<String>,
6359    nearest_tag: Option<String>,
6360    code_lines: u64,
6361    comment_lines: u64,
6362    blank_lines: u64,
6363    physical_lines: u64,
6364    files_analyzed: u64,
6365    files_skipped: u64,
6366    test_count: u64,
6367    project_label: String,
6368    html_url: Option<String>,
6369    has_pdf: bool,
6370    submodule_links: Vec<MetricsSubmoduleLink>,
6371    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
6372    #[serde(skip_serializing_if = "Option::is_none")]
6373    coverage_line_pct: Option<f64>,
6374}
6375
6376fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
6377    let mut links: Vec<MetricsSubmoduleLink> = vec![];
6378    let sub_dir = e
6379        .html_path
6380        .as_ref()
6381        .and_then(|p| p.parent())
6382        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6383    let Some(dir) = sub_dir else { return links };
6384    let Ok(rd) = std::fs::read_dir(dir) else {
6385        return links;
6386    };
6387    for entry_res in rd.flatten() {
6388        let fname = entry_res.file_name();
6389        let fname_str = fname.to_string_lossy();
6390        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6391            let stem = &fname_str[..fname_str.len() - 5];
6392            let display = stem[4..].replace('-', " ");
6393            links.push(MetricsSubmoduleLink {
6394                name: display,
6395                url: format!("/runs/{stem}/{}", e.run_id),
6396            });
6397        }
6398    }
6399    links.sort_by(|a, b| a.name.cmp(&b.name));
6400    links
6401}
6402
6403fn apply_submodule_filter(
6404    base: MetricsHistoryEntry,
6405    filter: &str,
6406    e: &sloc_core::history::RegistryEntry,
6407) -> Option<MetricsHistoryEntry> {
6408    let json_path = e.json_path.as_ref()?;
6409    let json_str = std::fs::read_to_string(json_path).ok()?;
6410    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
6411    let sub = run
6412        .submodule_summaries
6413        .iter()
6414        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
6415    let safe = sanitize_project_label(&sub.name);
6416    let artifact_key = format!("sub_{safe}");
6417    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
6418        || base.html_url.clone(),
6419        |run_dir| {
6420            let sub_path = run_dir.join(format!("{artifact_key}.html"));
6421            if sub_path.exists() {
6422                Some(format!("/runs/{artifact_key}/{}", e.run_id))
6423            } else {
6424                base.html_url.clone()
6425            }
6426        },
6427    );
6428    Some(MetricsHistoryEntry {
6429        code_lines: sub.code_lines,
6430        comment_lines: sub.comment_lines,
6431        blank_lines: sub.blank_lines,
6432        physical_lines: sub.total_physical_lines,
6433        files_analyzed: sub.files_analyzed,
6434        html_url: sub_html_url,
6435        has_pdf: false,
6436        submodule_links: vec![],
6437        ..base
6438    })
6439}
6440
6441#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
6442async fn api_metrics_history_handler(
6443    State(state): State<AppState>,
6444    Query(query): Query<MetricsHistoryQuery>,
6445) -> Response {
6446    let limit = query.limit.unwrap_or(50).min(500);
6447    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
6448
6449    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
6450        let reg = state.registry.lock().await;
6451        reg.entries
6452            .iter()
6453            .filter(|e| {
6454                query.root.as_ref().is_none_or(|root| {
6455                    let resolved = resolve_input_path(root);
6456                    let root_str = resolved.to_string_lossy().replace('\\', "/");
6457                    e.input_roots.iter().any(|r| r == &root_str)
6458                })
6459            })
6460            .take(limit)
6461            .cloned()
6462            .collect()
6463    };
6464
6465    let entries: Vec<MetricsHistoryEntry> = candidate_entries
6466        .into_iter()
6467        .filter_map(|e| {
6468            let tags = e
6469                .git_tags
6470                .as_deref()
6471                .map(|s| {
6472                    s.split(',')
6473                        .map(|t| t.trim().to_string())
6474                        .filter(|t| !t.is_empty())
6475                        .collect()
6476                })
6477                .unwrap_or_default();
6478            let html_url = e
6479                .html_path
6480                .as_ref()
6481                .filter(|p| p.exists())
6482                .map(|_| format!("/runs/html/{}", e.run_id));
6483            let nearest_tag = e.git_nearest_tag.clone();
6484            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
6485            let run_id_short: String = e
6486                .run_id
6487                .split('-')
6488                .next_back()
6489                .unwrap_or(&e.run_id)
6490                .chars()
6491                .take(7)
6492                .collect();
6493            let submodule_links = build_entry_submodule_links(&e);
6494            #[allow(clippy::cast_precision_loss)]
6495            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
6496                let pct = (e.summary.coverage_lines_hit as f64
6497                    / e.summary.coverage_lines_found as f64)
6498                    * 100.0;
6499                Some((pct * 10.0).round() / 10.0)
6500            } else {
6501                None
6502            };
6503            let base = MetricsHistoryEntry {
6504                run_id: e.run_id.clone(),
6505                run_id_short,
6506                timestamp: e.timestamp_utc.to_rfc3339(),
6507                commit: e.git_commit.clone(),
6508                branch: e.git_branch.clone(),
6509                tags,
6510                nearest_tag,
6511                code_lines: e.summary.code_lines,
6512                comment_lines: e.summary.comment_lines,
6513                blank_lines: e.summary.blank_lines,
6514                physical_lines: e.summary.total_physical_lines,
6515                files_analyzed: e.summary.files_analyzed,
6516                files_skipped: e.summary.files_skipped,
6517                test_count: e.summary.test_count,
6518                project_label: e.project_label.clone(),
6519                html_url,
6520                has_pdf,
6521                submodule_links,
6522                coverage_line_pct,
6523            };
6524            if let Some(ref filter) = submodule_filter {
6525                apply_submodule_filter(base, filter, &e)
6526            } else {
6527                Some(base)
6528            }
6529        })
6530        .collect();
6531
6532    Json(entries).into_response()
6533}
6534
6535// GET /api/metrics/submodules?root=<path>
6536// Returns the union of distinct submodule names found across all saved scan JSON artifacts
6537// for the given project root (or all roots if omitted).
6538#[derive(Deserialize)]
6539struct MetricsSubmodulesQuery {
6540    root: Option<String>,
6541}
6542
6543#[derive(Serialize)]
6544struct SubmoduleEntry {
6545    name: String,
6546    relative_path: String,
6547}
6548
6549async fn api_metrics_submodules_handler(
6550    State(state): State<AppState>,
6551    Query(query): Query<MetricsSubmodulesQuery>,
6552) -> Response {
6553    let json_paths: Vec<std::path::PathBuf> = {
6554        let reg = state.registry.lock().await;
6555        reg.entries
6556            .iter()
6557            .filter(|e| {
6558                query.root.as_ref().is_none_or(|root| {
6559                    let resolved = resolve_input_path(root);
6560                    let root_str = resolved.to_string_lossy().replace('\\', "/");
6561                    e.input_roots.iter().any(|r| r == &root_str)
6562                })
6563            })
6564            .filter_map(|e| e.json_path.clone())
6565            .collect()
6566    };
6567
6568    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6569    let mut result: Vec<SubmoduleEntry> = Vec::new();
6570
6571    for path in &json_paths {
6572        let Ok(json_str) = std::fs::read_to_string(path) else {
6573            continue;
6574        };
6575        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
6576            continue;
6577        };
6578        for sub in &run.submodule_summaries {
6579            if seen.insert(sub.name.clone()) {
6580                result.push(SubmoduleEntry {
6581                    name: sub.name.clone(),
6582                    relative_path: sub.relative_path.clone(),
6583                });
6584            }
6585        }
6586    }
6587
6588    result.sort_by(|a, b| a.name.cmp(&b.name));
6589    Json(result).into_response()
6590}
6591
6592// ── CI ingest endpoint ────────────────────────────────────────────────────────
6593// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
6594// server stores and displays results without cloning or scanning anything itself.
6595//
6596// POST /api/ingest?label=<optional_display_name>
6597// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
6598// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
6599
6600#[derive(Deserialize)]
6601struct IngestQuery {
6602    label: Option<String>,
6603}
6604
6605#[derive(Serialize)]
6606struct IngestResponse {
6607    run_id: String,
6608    view_url: String,
6609}
6610
6611async fn api_ingest_handler(
6612    State(state): State<AppState>,
6613    Query(q): Query<IngestQuery>,
6614    Json(run): Json<sloc_core::AnalysisRun>,
6615) -> Response {
6616    let label = q.label.unwrap_or_else(|| {
6617        run.input_roots
6618            .first()
6619            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
6620    });
6621
6622    let label_for_task = label.clone();
6623    let result = tokio::task::spawn_blocking(move || {
6624        let html = render_html(&run)?;
6625        let run_id = run.tool.run_id.clone();
6626        let run_id_safe = run_id.len() <= 128
6627            && !run_id.is_empty()
6628            && run_id
6629                .chars()
6630                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
6631        if !run_id_safe {
6632            anyhow::bail!(
6633                "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
6634            );
6635        }
6636        let project_label = sanitize_project_label(&label_for_task);
6637        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
6638        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
6639            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
6640            _ => project_label,
6641        };
6642        let (artifacts, _pending_pdf) = persist_run_artifacts(
6643            &run,
6644            &html,
6645            &output_dir,
6646            true,
6647            true,
6648            false,
6649            &label_for_task,
6650            &file_stem,
6651            RunResultContext::default(),
6652        )?;
6653        Ok::<_, anyhow::Error>((run_id, artifacts, run))
6654    })
6655    .await;
6656
6657    match result {
6658        Ok(Ok((run_id, artifacts, run))) => {
6659            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
6660            (
6661                StatusCode::CREATED,
6662                Json(IngestResponse {
6663                    view_url: format!("/view-reports?run_id={run_id}"),
6664                    run_id,
6665                }),
6666            )
6667                .into_response()
6668        }
6669        Ok(Err(e)) => error::internal(&format!("{e:#}")),
6670        Err(e) => error::internal(&format!("{e}")),
6671    }
6672}
6673
6674// ── Trend report page ─────────────────────────────────────────────────────────
6675// Protected. Interactive time-series chart page that loads scan history via
6676// /api/metrics/history and renders a vanilla-SVG line chart.
6677//
6678// GET /trend-reports
6679
6680#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
6681async fn trend_report_handler(
6682    State(state): State<AppState>,
6683    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6684) -> Response {
6685    auto_scan_watched_dirs(&state).await;
6686
6687    let watched_dirs_list: Vec<String> = {
6688        let wd = state.watched_dirs.lock().await;
6689        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6690    };
6691
6692    // Collect distinct project roots for the root selector dropdown.
6693    let roots: Vec<String> = {
6694        let reg = state.registry.lock().await;
6695        let mut seen = std::collections::BTreeSet::new();
6696        reg.entries
6697            .iter()
6698            .flat_map(|e| e.input_roots.iter().cloned())
6699            .filter(|r| seen.insert(r.clone()))
6700            .collect()
6701    };
6702
6703    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
6704    let nonce = &csp_nonce;
6705    let version = env!("CARGO_PKG_VERSION");
6706
6707    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
6708    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
6709    // of interactive controls — folder watching is managed by the host administrator.
6710    let watched_dirs_html: String = if state.server_mode {
6711        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()
6712    } else {
6713        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
6714            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
6715                .to_string()
6716        } else {
6717            watched_dirs_list
6718                .iter()
6719                .fold(String::new(), |mut s, d| {
6720                    use std::fmt::Write as _;
6721                    let escaped =
6722                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
6723                    write!(
6724                        s,
6725                        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>"#
6726                    ).expect("write to String is infallible");
6727                    s
6728                })
6729        };
6730        format!(
6731            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>"#
6732        )
6733    };
6734
6735    let html = format!(
6736        r##"<!doctype html>
6737<html lang="en">
6738<head>
6739  <meta charset="utf-8" />
6740  <meta name="viewport" content="width=device-width, initial-scale=1" />
6741  <title>OxideSLOC | Trend Reports</title>
6742  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6743  <style nonce="{nonce}">
6744    :root {{
6745      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
6746      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6747      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
6748      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6749      --info-bg:#eef3ff; --info-text:#4467d8;
6750    }}
6751    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
6752    *{{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;}}
6753    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
6754    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
6755    .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;}}
6756    @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));}}}}
6757    .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);}}
6758    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
6759    .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));}}
6760    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
6761    .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;}}
6762    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
6763    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
6764    @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; }} }}
6765    .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;}}
6766    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
6767    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
6768    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
6769    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
6770    .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;}}
6771    .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;}}
6772    .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;}}
6773    .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;}}
6774    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
6775    .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);}}
6776    .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;}}
6777    .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;}}
6778    .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;}}
6779    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
6780    .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;}}
6781    .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);}}
6782    .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;}}
6783    .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;}}
6784    .tz-select:focus{{border-color:var(--oxide);}}
6785    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
6786    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
6787    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
6788    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
6789    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
6790    .trend-title-block{{flex:1;min-width:0;}}
6791    .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;}}
6792    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
6793    .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;}}
6794    .chart-select:focus{{border-color:var(--accent);}}
6795    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
6796    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
6797    .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;}}
6798    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
6799    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
6800    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
6801    .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);}}
6802    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
6803    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
6804    .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;}}
6805    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
6806    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
6807    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
6808    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
6809    .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;}}
6810    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
6811    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
6812    .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);}}
6813    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
6814    .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;}}
6815    .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;}}
6816    .data-table tr:last-child td{{border-bottom:none;}}
6817    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
6818    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
6819    .table-wrap{{width:100%;overflow-x:auto;}}
6820    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
6821    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
6822    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
6823    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
6824    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
6825    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
6826    .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;}}
6827    .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;}}
6828    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
6829    .pagination-info{{font-size:13px;color:var(--muted);}}
6830    .pagination-btns{{display:flex;gap:6px;}}
6831    .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;}}
6832    .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;}}
6833    #scan-history-table col:nth-child(1){{width:155px;}}
6834    #scan-history-table col:nth-child(2){{width:240px;}}
6835    #scan-history-table col:nth-child(3){{width:82px;}}
6836    #scan-history-table col:nth-child(4){{width:82px;}}
6837    #scan-history-table col:nth-child(5){{width:90px;}}
6838    #scan-history-table col:nth-child(6){{width:90px;}}
6839    #scan-history-table col:nth-child(7){{width:88px;}}
6840    #scan-history-table col:nth-child(8){{width:150px;}}
6841    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
6842    .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;}}
6843    .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;}}
6844    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
6845    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
6846    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
6847    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
6848    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
6849    .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;}}
6850    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
6851    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
6852    .watched-chip-rm:hover{{color:var(--oxide);}}
6853    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
6854    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
6855    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
6856    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
6857    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
6858    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
6859    a.run-link:hover{{text-decoration:underline;}}
6860    .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);}}
6861    .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);}}
6862    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
6863    .metric-num{{font-weight:700;color:var(--text);}}
6864    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
6865    .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;}}
6866    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
6867    .btn.primary:hover{{opacity:.9;}}
6868    .rpt-btn{{min-width:58px;justify-content:center;}}
6869    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
6870    .report-cell{{overflow:visible!important;white-space:normal!important;}}
6871    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
6872    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
6873    .submod-details summary::-webkit-details-marker{{display:none;}}
6874    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
6875    .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;}}
6876    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
6877    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
6878    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
6879    .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;}}
6880    .export-btn:hover{{background:var(--line);}}
6881    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
6882    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
6883    .site-footer a{{color:var(--muted);}}
6884    .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;}}
6885    .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;}}
6886    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
6887  </style>
6888</head>
6889<body>
6890  <div class="background-watermarks" aria-hidden="true">
6891    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6892    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6893    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6894    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6895    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6896    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6897  </div>
6898  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6899  <div class="top-nav">
6900    <div class="top-nav-inner">
6901      <a class="brand" href="/">
6902        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6903        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
6904      </a>
6905      <div class="nav-right">
6906        <a class="nav-pill" href="/">Home</a>
6907        <div class="nav-dropdown">
6908          <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>
6909          <div class="nav-dropdown-menu">
6910            <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>
6911          </div>
6912        </div>
6913        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6914        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
6915        <div class="nav-dropdown">
6916          <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>
6917          <div class="nav-dropdown-menu">
6918            <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>
6919          </div>
6920        </div>
6921        <div class="server-status-wrap" id="server-status-wrap">
6922          <div class="nav-pill server-online-pill" id="server-status-pill">
6923            <span class="status-dot" id="status-dot"></span>
6924            <span id="server-status-label">Server</span>
6925            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
6926          </div>
6927          <div class="server-status-tip">
6928            OxideSLOC is running — accessible on your network.
6929            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
6930          </div>
6931        </div>
6932        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
6933          <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>
6934        </button>
6935        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6936          <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>
6937          <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>
6938        </button>
6939      </div>
6940    </div>
6941  </div>
6942
6943  <div class="page">
6944    {watched_dirs_html}
6945    <div class="summary-strip" id="trend-stats"></div>
6946    <div class="panel">
6947      <div class="trend-header">
6948        <div class="trend-title-block">
6949          <h1>Trend Reports</h1>
6950          <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>
6951          <span class="chart-hint-inline">
6952            <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>
6953            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
6954          </span>
6955        </div>
6956        <div class="chart-actions">
6957          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
6958            <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>
6959            Clean up old runs
6960          </button>
6961          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
6962            <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>
6963            Export Excel
6964          </button>
6965          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
6966            <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>
6967            Export PNG
6968          </button>
6969        </div>
6970      </div>
6971
6972      <div class="controls-centered">
6973        <label>Project Root:
6974          <select class="chart-select" id="root-sel">
6975            <option value="">All projects</option>
6976          </select>
6977        </label>
6978        <label>Y Metric:
6979          <select class="chart-select" id="y-sel">
6980            <option value="code_lines">Code Lines</option>
6981            <option value="comment_lines">Comment Lines</option>
6982            <option value="blank_lines">Blank Lines</option>
6983            <option value="physical_lines">Physical Lines</option>
6984            <option value="files_analyzed">Files Analyzed</option>
6985          </select>
6986        </label>
6987        <label>X Axis:
6988          <select class="chart-select" id="x-sel">
6989            <option value="time">By Time</option>
6990            <option value="commit">By Commit</option>
6991            <option value="release">By Release</option>
6992            <option value="tag">Tagged Commits</option>
6993          </select>
6994        </label>
6995        <label id="submodule-label" style="display:none;">Submodule:
6996          <select class="chart-select" id="sub-sel">
6997            <option value="">All (project total)</option>
6998          </select>
6999        </label>
7000        <label>Chart Size:
7001          <select class="chart-select" id="scale-sel">
7002            <option value="0.75">Compact</option>
7003            <option value="1.2" selected>Normal</option>
7004            <option value="1.38">Large</option>
7005          </select>
7006        </label>
7007      </div>
7008
7009      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
7010      <div id="data-table-wrap" style="overflow-x:auto;"></div>
7011    </div>
7012  </div>
7013
7014  <script nonce="{nonce}">
7015    (function() {{
7016      // Theme persistence
7017      var b = document.body;
7018      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7019      var tgl = document.getElementById('theme-toggle');
7020      if (tgl) tgl.addEventListener('click', function() {{
7021        var d = b.classList.toggle('dark-theme');
7022        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7023      }});
7024
7025      // Watermark randomizer
7026      (function() {{
7027        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7028        if (!wms.length) return;
7029        var placed = [];
7030        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;}}
7031        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];}}
7032        var half=Math.floor(wms.length/2);
7033        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;}});
7034      }})();
7035
7036      // Code particles
7037      (function() {{
7038        var container = document.getElementById('code-particles');
7039        if (!container) return;
7040        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'];
7041        for (var i = 0; i < 38; i++) {{
7042          (function(idx) {{
7043            var el = document.createElement('span');
7044            el.className = 'code-particle';
7045            el.textContent = snippets[idx % snippets.length];
7046            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7047            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7048            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7049            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';
7050            container.appendChild(el);
7051          }})(i);
7052        }}
7053      }})();
7054
7055      // Watched folder picker
7056      (function() {{
7057        var btn = document.getElementById('add-watched-btn');
7058        if (!btn) return;
7059        btn.addEventListener('click', function() {{
7060          fetch('/pick-directory?kind=reports')
7061            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
7062            .then(function(data) {{
7063              if (!data.cancelled && data.selected_path) {{
7064                var form = document.createElement('form');
7065                form.method = 'POST';
7066                form.action = '/watched-dirs/add';
7067                var ri = document.createElement('input');
7068                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7069                var fi = document.createElement('input');
7070                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7071                form.appendChild(ri); form.appendChild(fi);
7072                document.body.appendChild(form);
7073                form.submit();
7074              }}
7075            }})
7076            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7077        }});
7078      }})();
7079
7080      // Settings / color-scheme modal
7081      (function() {{
7082        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'}}];
7083        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);}});}}
7084        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7085        var btn=document.getElementById('settings-btn');if(!btn)return;
7086        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7087        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>';
7088        document.body.appendChild(m);
7089        var g=document.getElementById('scheme-grid');
7090        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);}});
7091        var cl=document.getElementById('settings-close');
7092        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);
7093        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');}});
7094        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7095        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7096      }})();
7097    }})();
7098
7099    var ROOTS = {roots_json};
7100    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7101    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
7102    var allData = [];
7103
7104    // Populate root selector
7105    var rootSel = document.getElementById('root-sel');
7106    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
7107
7108    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();}}
7109    function fmtFull(n){{return Number(n).toLocaleString();}}
7110    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
7111
7112    // Tooltip
7113    var tt = document.createElement('div');
7114    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);';
7115    document.body.appendChild(tt);
7116    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
7117    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';}}
7118    function hideTT(){{tt.style.display='none';}}
7119
7120    function statExact(compact, full){{
7121      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
7122    }}
7123    function statVal(n){{
7124      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
7125    }}
7126
7127    function updateStats(data){{
7128      var statsEl=document.getElementById('trend-stats');
7129      if(!statsEl)return;
7130      if(!data||!data.length){{statsEl.innerHTML='';return;}}
7131      var yKey=document.getElementById('y-sel').value;
7132      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7133      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7134      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
7135      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
7136      var absDelta=Math.abs(delta);
7137      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
7138      var deltaExact=statExact(deltaCompact,deltaFull);
7139      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
7140      statsEl.innerHTML=
7141        '<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>'+
7142        '<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>'+
7143        '<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>'+
7144        '<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>';
7145    }}
7146
7147    var subSel = document.getElementById('sub-sel');
7148    var subLabel = document.getElementById('submodule-label');
7149
7150    function populateSubmodules(root){{
7151      if(!subSel||!subLabel)return;
7152      while(subSel.options.length>1)subSel.remove(1);
7153      subSel.value='';
7154      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
7155      fetch(url)
7156        .then(function(r){{return r.json();}})
7157        .then(function(subs){{
7158          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
7159          subs.forEach(function(s){{
7160            var o=document.createElement('option');
7161            o.value=s.name;
7162            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
7163            subSel.appendChild(o);
7164          }});
7165          subLabel.style.display='';
7166        }})
7167        .catch(function(){{subLabel.style.display='none';}});
7168    }}
7169
7170    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
7171
7172    function loadAndRender(){{
7173      var root = rootSel.value;
7174      var sub = subSel ? subSel.value : '';
7175      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
7176      document.getElementById('data-table-wrap').innerHTML='';
7177      var url = '/api/metrics/history?limit=100'
7178        + (root ? '&root='+encodeURIComponent(root) : '')
7179        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
7180      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
7181        allData = data;
7182        render(data);
7183        updateStats(data);
7184      }}).catch(function(){{
7185        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>';
7186      }});
7187    }}
7188
7189    function render(data){{
7190      var yKey = document.getElementById('y-sel').value;
7191      var xMode = document.getElementById('x-sel').value;
7192
7193      // Filter for tag/release mode
7194      var pts = data;
7195      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
7196
7197      // Sort oldest-first for the line chart
7198      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7199
7200      var wrap = document.getElementById('chart-wrap');
7201      if(!pts.length){{
7202        var emptyMsg = (xMode === 'tag')
7203          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
7204          : 'No scan data found for the selected filters.';
7205        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
7206        renderTable([]);
7207        return;
7208      }}
7209
7210      var scaleEl=document.getElementById('scale-sel');
7211      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
7212      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;
7213      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
7214
7215      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
7216
7217      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">';
7218      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>';
7219
7220      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
7221
7222      // Grid + Y axis ticks
7223      for(var ti=0;ti<=5;ti++){{
7224        var gy=PT+CH-Math.round(ti/5*CH);
7225        var gv=Math.round(ti/5*maxY);
7226        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
7227        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
7228      }}
7229
7230      // X axis labels (every N-th point to avoid crowding)
7231      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
7232      pts.forEach(function(d,i){{
7233        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7234        if(i%labelEvery===0||i===pts.length-1){{
7235          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)));
7236          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>';
7237        }}
7238      }});
7239
7240      // Axis label
7241      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
7242      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>';
7243      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>';
7244
7245      // Area fill + line path
7246      var pathD='';
7247      pts.forEach(function(d,i){{
7248        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7249        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7250        pathD+=(i===0?'M':'L')+x+','+y;
7251      }});
7252      if(pts.length>1){{
7253        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
7254        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
7255      }}
7256      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
7257
7258      // Data points (clickable) + permanent value labels
7259      var showLabels = pts.length <= 40;
7260      var labelEveryN = pts.length > 20 ? 2 : 1;
7261      pts.forEach(function(d,i){{
7262        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
7263        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
7264        var hasTags=d.tags&&d.tags.length>0;
7265        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
7266        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
7267        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+'"/>';
7268        if(showLabels && i%labelEveryN===0){{
7269          var lx=x, ly=y-r-5;
7270          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>';
7271        }}
7272      }});
7273
7274      svg+='</svg>';
7275      wrap.innerHTML=svg;
7276
7277      // Attach point tooltips
7278      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
7279        c.addEventListener('mouseover',function(e){{
7280          var d=pts[parseInt(this.dataset.idx)];
7281          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(''):'';
7282          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>':'';
7283          showTT(e,
7284            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
7285            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
7286            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
7287            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
7288          );
7289          this.setAttribute('r','8');
7290        }});
7291        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
7292        c.addEventListener('mousemove',moveTT);
7293        c.addEventListener('click',function(){{
7294          var d=pts[parseInt(this.dataset.idx)];
7295          if(d.html_url) window.open(d.html_url,'_blank');
7296        }});
7297      }});
7298
7299      renderTable(pts, yKey);
7300    }}
7301
7302    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
7303    var shProjFilter='', shBranchFilter='';
7304
7305    function fmtPST(isoStr){{
7306      if(!isoStr)return'';
7307      var d=new Date(isoStr);
7308      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
7309      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);}}
7310      function p(n){{return n<10?'0'+n:String(n);}}
7311      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++;}}}}
7312      var yr=d.getUTCFullYear();
7313      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
7314      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
7315      var isDST=d>=dstStart&&d<dstEnd;
7316      var off=isDST?-7*3600*1000:-8*3600*1000;
7317      var lbl=isDST?'PDT':'PST';
7318      var loc=new Date(d.getTime()+off);
7319      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
7320    }}
7321
7322    function getShRows(){{
7323      var proj=shProjFilter.toLowerCase().trim();
7324      var branch=shBranchFilter;
7325      return shData.filter(function(d){{
7326        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
7327        if(branch&&(d.branch||'')!==branch)return false;
7328        return true;
7329      }});
7330    }}
7331
7332    function renderShPage(){{
7333      var filtered=getShRows();
7334      if(shSortCol){{
7335        filtered.sort(function(a,b){{
7336          var va,vb;
7337          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
7338          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
7339          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
7340          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
7341          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
7342          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
7343        }});
7344      }}
7345      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
7346      shPage=Math.min(shPage,totalPages);
7347      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
7348      var visible=filtered.slice(start,end);
7349      var tbody=document.getElementById('sh-tbody');
7350      if(!tbody)return;
7351      tbody.innerHTML=visible.map(function(d){{
7352        var tsHtml=esc(fmtPST(d.timestamp));
7353        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>';
7354        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>';
7355        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
7356        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
7357        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
7358        var reportCell='';
7359        if(d.html_url){{
7360          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
7361          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>';}}
7362          reportCell+='</div>';
7363        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
7364        if(d.submodule_links&&d.submodule_links.length){{
7365          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
7366          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
7367          reportCell+='</div></details>';
7368        }}
7369        return '<tr>'
7370          +'<td>'+tsHtml+'</td>'
7371          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
7372          +'<td>'+runIdHtml+'</td>'
7373          +'<td>'+commitHtml+'</td>'
7374          +'<td>'+branchHtml+'</td>'
7375          +'<td>'+tags+'</td>'
7376          +'<td class="num">'+metricHtml+'</td>'
7377          +'<td class="report-cell">'+reportCell+'</td>'
7378          +'</tr>';
7379      }}).join('');
7380      var pgRange=document.getElementById('sh-pg-range');
7381      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
7382      var pgInfo=document.getElementById('sh-pg-info');
7383      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
7384      var pgBtns=document.getElementById('sh-pg-btns');
7385      if(pgBtns){{
7386        pgBtns.innerHTML='';
7387        function mkPgBtn(lbl,pg,active,disabled){{
7388          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
7389          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
7390          return b;
7391        }}
7392        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
7393        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
7394        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
7395        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
7396      }}
7397    }}
7398
7399    function wireTableBehavior(){{
7400      var pf=document.getElementById('sh-proj-filter');
7401      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
7402      var bf=document.getElementById('sh-branch-filter');
7403      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
7404      var rb=document.getElementById('sh-reset-btn');
7405      if(rb)rb.addEventListener('click',function(){{
7406        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
7407        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
7408        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
7409        document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
7410        renderShPage();
7411      }});
7412      var pps=document.getElementById('sh-per-page');
7413      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
7414      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
7415      ths.forEach(function(th){{
7416        th.addEventListener('click',function(e){{
7417          if(e.target.classList.contains('col-resize-handle'))return;
7418          var col=th.dataset.col;
7419          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
7420          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
7421          th.classList.add('sort-'+shSortOrder);
7422          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
7423          shPage=1;renderShPage();
7424        }});
7425      }});
7426      var table=document.getElementById('scan-history-table');
7427      if(!table)return;
7428      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
7429      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
7430      allThs.forEach(function(th,i){{
7431        var handle=th.querySelector('.col-resize-handle');
7432        if(!handle||!cols[i])return;
7433        var startX,startW;
7434        handle.addEventListener('mousedown',function(e){{
7435          e.stopPropagation();e.preventDefault();
7436          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
7437          handle.classList.add('dragging');
7438          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
7439          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
7440          document.addEventListener('mousemove',onMove);
7441          document.addEventListener('mouseup',onUp);
7442        }});
7443      }});
7444    }}
7445
7446    function renderTable(pts, yKey){{
7447      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
7448      var wrap=document.getElementById('data-table-wrap');
7449      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
7450      var yLabel=Y_LABELS[yKey]||yKey||'';
7451      shData=pts.slice().reverse();
7452      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
7453      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
7454      var branches={{}};
7455      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
7456      var branchOpts='<option value="">All branches</option>';
7457      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
7458      wrap.innerHTML=
7459        '<div class="chart-section-header">SCAN HISTORY</div>'+
7460        '<div class="filter-row">'+
7461          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project\u2026">'+
7462          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
7463          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
7464        '</div>'+
7465        '<div class="table-wrap">'+
7466        '<table id="scan-history-table" class="data-table">'+
7467        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
7468        '<thead><tr id="sh-thead">'+
7469        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7470        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7471        '<th>Run ID<div class="col-resize-handle"></div></th>'+
7472        '<th>Commit<div class="col-resize-handle"></div></th>'+
7473        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
7474        '<th>Tags<div class="col-resize-handle"></div></th>'+
7475        '<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>'+
7476        '<th>Report<div class="col-resize-handle"></div></th>'+
7477        '</tr></thead>'+
7478        '<tbody id="sh-tbody"></tbody>'+
7479        '</table>'+
7480        '</div>'+
7481        '<div class="pagination">'+
7482          '<span class="pagination-info" id="sh-pg-info"></span>'+
7483          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
7484          '<div style="display:flex;align-items:center;gap:8px;">'+
7485            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
7486            '<select class="filter-select" id="sh-per-page">'+
7487              '<option value="10">10 per page</option>'+
7488              '<option value="25" selected>25 per page</option>'+
7489              '<option value="50">50 per page</option>'+
7490              '<option value="100">100 per page</option>'+
7491            '</select>'+
7492            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
7493          '</div>'+
7494        '</div>';
7495      wireTableBehavior();
7496      renderShPage();
7497    }}
7498
7499    function exportXLSX(){{
7500      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
7501      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
7502      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
7503      var s1R=sorted.map(function(d){{
7504        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||''];
7505      }});
7506      var pm={{}};
7507      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
7508      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'];
7509      var s2R=Object.keys(pm).map(function(p){{
7510        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
7511        var lat=sc[sc.length-1],fst=sc[0];
7512        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
7513        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);
7514        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];
7515      }});
7516      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
7517      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
7518      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
7519      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
7520    }}
7521
7522    function buildXLSX(sheets,chartRows,chartRows2){{
7523      function s2b(s){{return new TextEncoder().encode(s);}}
7524      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
7525      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;}}
7526      function crc32(d){{
7527        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;}}}}
7528        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
7529      }}
7530      function buildSheet(hdr,rows,drawRid,withCtrl){{
7531        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
7532        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
7533        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
7534        x+='<row r="1">';
7535        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
7536        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>';}}
7537        x+='</row>';
7538        rows.forEach(function(row,ri){{
7539          var rn=ri+2;
7540          x+='<row r="'+rn+'">';
7541          row.forEach(function(cell,ci){{
7542            var addr=col2l(ci+1)+rn;
7543            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
7544            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
7545          }});
7546          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>';}}
7547          x+='</row>';
7548        }});
7549        x+='</sheetData>';
7550        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>';}}
7551        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
7552        return x+'</worksheet>';
7553      }}
7554      function buildChartXML(rows){{
7555        var sn="'Scan History'";
7556        var nr=rows.length,er=nr+1;
7557        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'}}];
7558        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7559        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">';
7560        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7561        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7562        sd.forEach(function(s,i){{
7563          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7564          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>';
7565          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7566          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>';
7567          var dlp=(i===2)?'b':'t';
7568          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>';
7569          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7570          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7571          x+='</c:strCache></c:strRef></c:cat>';
7572          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+'"/>';
7573          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7574          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7575        }});
7576        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
7577        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>';
7578        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>';
7579        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7580        return x;
7581      }}
7582      function buildChartXML2(rows){{
7583        var sn="'By Project'";
7584        var nr=rows.length,er=nr+1;
7585        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'}}];
7586        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7587        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">';
7588        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
7589        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7590        sd.forEach(function(s,i){{
7591          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
7592          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>';
7593          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
7594          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>';
7595          var dlp=(i===2)?'b':'t';
7596          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>';
7597          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7598          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7599          x+='</c:strCache></c:strRef></c:cat>';
7600          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+'"/>';
7601          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
7602          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7603        }});
7604        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
7605        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>';
7606        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>';
7607        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
7608        return x;
7609      }}
7610      function buildChartXML3(rows){{
7611        var sn="'Scan History'";
7612        var nr=rows.length,er=nr+1;
7613        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7614        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">';
7615        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
7616        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
7617        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
7618        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>';
7619        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
7620        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>';
7621        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>';
7622        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
7623        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
7624        x+='</c:strCache></c:strRef></c:cat>';
7625        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+'"/>';
7626        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
7627        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
7628        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
7629        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>';
7630        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>';
7631        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>';
7632        return x;
7633      }}
7634      var hasChart=!!(chartRows&&chartRows.length);
7635      var nr=hasChart?chartRows.length:0;
7636      var hasChart2=!!(chartRows2&&chartRows2.length);
7637      var nr2=hasChart2?chartRows2.length:0;
7638      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>';
7639      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"/>';
7640      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
7641      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"/>';}}
7642      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"/>';}}
7643      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
7644      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>';
7645      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
7646      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"/>';}});
7647      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
7648      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>';
7649      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
7650      wbx+='</sheets></workbook>';
7651      var files=[
7652        {{name:'[Content_Types].xml',data:s2b(ct)}},
7653        {{name:'_rels/.rels',data:s2b(dotrels)}},
7654        {{name:'xl/workbook.xml',data:s2b(wbx)}},
7655        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
7656        {{name:'xl/styles.xml',data:s2b(styl)}}
7657      ];
7658      // Chart embedded directly in Scan History (sheet1); By Project is plain
7659      sheets.forEach(function(s,i){{
7660        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)))}});
7661      }});
7662      if(hasChart){{
7663        var fromRow=nr+4,toRow=nr+24;
7664        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>')}});
7665        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7666        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">';
7667        drx+='<xdr:twoCellAnchor editAs="twoCell">';
7668        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>';
7669        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>';
7670        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7671        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7672        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7673        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7674        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
7675        var focRow=toRow+2,focRowEnd=toRow+22;
7676        drx+='<xdr:twoCellAnchor editAs="twoCell">';
7677        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>';
7678        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>';
7679        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7680        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7681        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7682        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
7683        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
7684        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
7685        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>')}});
7686        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
7687        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
7688      }}
7689      if(hasChart2){{
7690        var fromRow2=nr2+4,toRow2=nr2+24;
7691        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>')}});
7692        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7693        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">';
7694        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
7695        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>';
7696        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>';
7697        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
7698        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
7699        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
7700        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
7701        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
7702        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
7703        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>')}});
7704        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
7705      }}
7706      var parts=[],offsets=[],total=0;
7707      files.forEach(function(f){{
7708        offsets.push(total);
7709        var nb=s2b(f.name),crc=crc32(f.data);
7710        var h=new DataView(new ArrayBuffer(30+nb.length));
7711        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
7712        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
7713        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
7714        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
7715        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
7716        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
7717        total+=30+nb.length+f.data.length;
7718      }});
7719      var cdStart=total;
7720      files.forEach(function(f,fi){{
7721        var nb=s2b(f.name),crc=crc32(f.data);
7722        var cd=new DataView(new ArrayBuffer(46+nb.length));
7723        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
7724        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
7725        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
7726        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
7727        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
7728        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
7729        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
7730      }});
7731      var cdSz=total-cdStart;
7732      var eocd=new DataView(new ArrayBuffer(22));
7733      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
7734      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
7735      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
7736      parts.push(new Uint8Array(eocd.buffer));
7737      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
7738      var out=new Uint8Array(sz);var off=0;
7739      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
7740      return out.buffer;
7741    }}
7742
7743    function exportPNG(){{
7744      var svgEl=document.querySelector('#chart-wrap svg');
7745      if(!svgEl){{alert('No chart to export yet.');return;}}
7746      var svgStr=new XMLSerializer().serializeToString(svgEl);
7747      var vb=svgEl.viewBox.baseVal,scale=2;
7748      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
7749      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
7750      var url=URL.createObjectURL(blob);
7751      var img=new Image();
7752      img.onload=function(){{
7753        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
7754        var ctx=canvas.getContext('2d');
7755        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
7756        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
7757        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
7758        URL.revokeObjectURL(url);
7759        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
7760      }};
7761      img.src=url;
7762    }}
7763
7764    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
7765      var el=document.getElementById(id);
7766      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
7767    }});
7768    rootSel.addEventListener('change',function(){{
7769      populateSubmodules(rootSel.value);
7770      loadAndRender();
7771    }});
7772    if(subSel)subSel.addEventListener('change',loadAndRender);
7773
7774    var xlsxBtn=document.getElementById('export-xlsx-btn');
7775    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
7776    var pngBtn=document.getElementById('export-png-btn');
7777    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
7778
7779    // ── Clean-up modal ───────────────────────────────────────────────────────
7780    (function(){{
7781      var triggerBtn=document.getElementById('cleanup-runs-btn');
7782      if(!triggerBtn)return;
7783      var modal=document.createElement('div');
7784      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;';
7785      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);">'
7786        +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
7787        +'<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>'
7788        +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
7789        +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
7790        +'<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;">'
7791        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
7792        +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
7793        +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
7794        +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
7795        +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
7796        +'</div></div>';
7797      document.body.appendChild(modal);
7798      triggerBtn.addEventListener('click',function(){{
7799        document.getElementById('cleanup-status').style.display='none';
7800        modal.style.display='flex';
7801      }});
7802      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
7803      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
7804      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
7805        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
7806        var confirmBtn=this;
7807        confirmBtn.disabled=true;
7808        var status=document.getElementById('cleanup-status');
7809        status.style.display='block';
7810        status.style.background='#dbeafe';status.style.color='#1e40af';
7811        status.textContent='Deleting\u2026';
7812        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
7813        .then(function(resp){{
7814          return resp.json().then(function(d){{
7815            if(resp.ok){{
7816              status.style.background='#dcfce7';status.style.color='#166534';
7817              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
7818              setTimeout(function(){{window.location.reload();}},1500);
7819            }}else{{
7820              status.style.background='#fee2e2';status.style.color='#991b1b';
7821              status.textContent='Error: '+(d.error||'Unexpected error');
7822              confirmBtn.disabled=false;
7823            }}
7824          }});
7825        }})
7826        .catch(function(e){{
7827          status.style.background='#fee2e2';status.style.color='#991b1b';
7828          status.textContent='Network error: '+String(e);
7829          confirmBtn.disabled=false;
7830        }});
7831      }});
7832    }})();
7833
7834    populateSubmodules(rootSel.value);
7835    loadAndRender();
7836
7837    (function randomizeWatermarks() {{
7838      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7839      if (!wms.length) return;
7840      var placed = [];
7841      function tooClose(top, left) {{
7842        for (var i = 0; i < placed.length; i++) {{
7843          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
7844          if (dt < 16 && dl < 12) return true;
7845        }}
7846        return false;
7847      }}
7848      function pick(leftBand) {{
7849        for (var attempt = 0; attempt < 50; attempt++) {{
7850          var top = Math.random() * 88 + 2;
7851          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7852          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
7853        }}
7854        var top = Math.random() * 88 + 2;
7855        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7856        placed.push([top, left]); return [top, left];
7857      }}
7858      var half = Math.floor(wms.length / 2);
7859      wms.forEach(function (img, i) {{
7860        var pos = pick(i < half);
7861        var size = Math.floor(Math.random() * 100 + 120);
7862        var rot = (Math.random() * 360).toFixed(1);
7863        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
7864        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;
7865      }});
7866    }})();
7867    (function spawnCodeParticles() {{
7868      var container = document.getElementById('code-particles');
7869      if (!container) return;
7870      var snippets = [
7871        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
7872        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
7873        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
7874        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
7875        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
7876      ];
7877      var count = 38;
7878      for (var i = 0; i < count; i++) {{
7879        (function(idx) {{
7880          var el = document.createElement('span');
7881          el.className = 'code-particle';
7882          el.textContent = snippets[idx % snippets.length];
7883          var left = Math.random() * 94 + 2;
7884          var top = Math.random() * 88 + 6;
7885          var dur = (Math.random() * 10 + 9).toFixed(1);
7886          var delay = (Math.random() * 18).toFixed(1);
7887          var rot = (Math.random() * 26 - 13).toFixed(1);
7888          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7889          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
7890          container.appendChild(el);
7891        }})(i);
7892      }}
7893    }})();
7894  </script>
7895  <footer class="site-footer">
7896    local code analysis - metrics, history and reports
7897    &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>
7898    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7899    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7900    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7901    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
7902  </footer>
7903  <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>
7904</body>
7905</html>"##,
7906    );
7907
7908    Html(html).into_response()
7909}
7910
7911fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
7912    use std::collections::HashMap;
7913    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
7914        return vec![];
7915    }
7916    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
7917    for rec in per_file_records {
7918        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
7919            let e = totals.entry(lang.display_name().to_string()).or_default();
7920            e.0 += u64::from(cov.lines_found);
7921            e.1 += u64::from(cov.lines_hit);
7922        }
7923    }
7924    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
7925    let mut pairs: Vec<(String, f64)> = totals
7926        .into_iter()
7927        .filter(|(_, (found, _))| *found > 0)
7928        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
7929        .collect();
7930    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7931    pairs
7932        .iter()
7933        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
7934        .collect()
7935}
7936
7937fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
7938    let mut high = 0u64;
7939    let mut mid = 0u64;
7940    let mut low = 0u64;
7941    for rec in per_file_records {
7942        if let Some(cov) = &rec.coverage {
7943            if cov.lines_found == 0 {
7944                continue;
7945            }
7946            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
7947            if pct >= 80.0 {
7948                high += 1;
7949            } else if pct >= 50.0 {
7950                mid += 1;
7951            } else {
7952                low += 1;
7953            }
7954        }
7955    }
7956    (high, mid, low)
7957}
7958
7959fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
7960    let mut arr: Vec<serde_json::Value> = per_file_records
7961        .iter()
7962        .filter_map(|rec| {
7963            rec.coverage.as_ref().map(|cov| {
7964                let line_pct = if cov.lines_found > 0 {
7965                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
7966                        / 10.0
7967                } else {
7968                    0.0
7969                };
7970                let fn_pct = if cov.functions_found > 0 {
7971                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
7972                        .round()
7973                        / 10.0
7974                } else {
7975                    -1.0
7976                };
7977                serde_json::json!({
7978                    "rel": rec.relative_path,
7979                    "lang": rec.language.map_or("?", |l| l.display_name()),
7980                    "line_pct": line_pct,
7981                    "fn_pct": fn_pct,
7982                    "lhit": cov.lines_hit,
7983                    "lfound": cov.lines_found,
7984                    "fhit": cov.functions_hit,
7985                    "ffound": cov.functions_found,
7986                })
7987            })
7988        })
7989        .collect();
7990    arr.sort_by(|a, b| {
7991        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
7992        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
7993        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
7994    });
7995    arr
7996}
7997
7998#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
7999fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
8000    let mut langs: Vec<&sloc_core::LanguageSummary> = run
8001        .totals_by_language
8002        .iter()
8003        .filter(|l| l.test_count > 0)
8004        .collect();
8005    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8006    let lang_tests: Vec<serde_json::Value> = langs
8007        .iter()
8008        .map(|l| {
8009            let d = if l.code_lines > 0 {
8010                l.test_count as f64 / l.code_lines as f64 * 1000.0
8011            } else {
8012                0.0
8013            };
8014            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8015                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8016                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8017        })
8018        .collect();
8019    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
8020    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8021    let t = &run.summary_totals;
8022    let total_tests = t.test_count;
8023    let density = if t.code_lines > 0 {
8024        total_tests as f64 / t.code_lines as f64 * 1000.0
8025    } else {
8026        0.0
8027    };
8028    let most_tested = langs.first().map_or_else(
8029        || "\u{2014}".to_string(),
8030        |l| l.language.display_name().to_string(),
8031    );
8032    let test_files: u64 = run
8033        .per_file_records
8034        .iter()
8035        .filter(|f| f.raw_line_categories.test_count > 0)
8036        .count() as u64;
8037    let cov_line = if t.coverage_lines_found > 0 {
8038        format!(
8039            "{:.1}",
8040            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
8041        )
8042    } else {
8043        "0".to_string()
8044    };
8045    let cov_fn = if t.coverage_functions_found > 0 {
8046        format!(
8047            "{:.1}",
8048            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
8049        )
8050    } else {
8051        "0".to_string()
8052    };
8053    let cov_branch = if t.coverage_branches_found > 0 {
8054        format!(
8055            "{:.1}",
8056            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
8057        )
8058    } else {
8059        "0".to_string()
8060    };
8061    let has_cov = !cov_arr.is_empty();
8062    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
8063    serde_json::json!({
8064        "totals": {
8065            "test_count": total_tests,
8066            "assertions": t.test_assertion_count,
8067            "suites": t.test_suite_count,
8068            "test_files": test_files,
8069            "total_files": t.files_analyzed,
8070            "density_str": format!("{density:.1}"),
8071            "most_tested": most_tested,
8072            "langs_with_tests": langs.len(),
8073            "cov_line": cov_line,
8074            "cov_fn": cov_fn,
8075            "cov_branch": cov_branch,
8076        },
8077        "lang_tests": lang_tests,
8078        "cov": cov_arr,
8079        "cov_tiers": {"high": high, "mid": mid, "low": low},
8080        "file_cov": file_cov_arr,
8081        "has_coverage": has_cov,
8082        "submodules": {},
8083    })
8084}
8085
8086#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
8087fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
8088    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
8089        .language_summaries
8090        .iter()
8091        .filter(|l| l.test_count > 0)
8092        .collect();
8093    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8094    let lang_tests: Vec<serde_json::Value> = langs
8095        .iter()
8096        .map(|l| {
8097            let d = if l.code_lines > 0 {
8098                l.test_count as f64 / l.code_lines as f64 * 1000.0
8099            } else {
8100                0.0
8101            };
8102            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
8103                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
8104                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
8105        })
8106        .collect();
8107    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
8108    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
8109    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
8110    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
8111    let density = if sub.code_lines > 0 {
8112        total_tests as f64 / sub.code_lines as f64 * 1000.0
8113    } else {
8114        0.0
8115    };
8116    let most_tested = langs.first().map_or_else(
8117        || "\u{2014}".to_string(),
8118        |l| l.language.display_name().to_string(),
8119    );
8120    serde_json::json!({
8121        "totals": {
8122            "test_count": total_tests,
8123            "assertions": total_assertions,
8124            "suites": total_suites,
8125            "test_files": test_files_approx,
8126            "total_files": sub.files_analyzed,
8127            "density_str": format!("{density:.1}"),
8128            "most_tested": most_tested,
8129            "langs_with_tests": langs.len(),
8130            "cov_line": "0",
8131            "cov_fn": "0",
8132            "cov_branch": "0",
8133        },
8134        "lang_tests": lang_tests,
8135        "cov": [],
8136        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
8137        "has_coverage": false,
8138    })
8139}
8140
8141fn compute_cov_json_str(run: &AnalysisRun) -> String {
8142    use std::collections::HashMap;
8143    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
8144    for rec in &run.per_file_records {
8145        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
8146            let e = totals.entry(lang.display_name().to_string()).or_default();
8147            e.0 += u64::from(cov.lines_found);
8148            e.1 += u64::from(cov.lines_hit);
8149        }
8150    }
8151    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
8152    let mut pairs: Vec<(String, f64)> = totals
8153        .into_iter()
8154        .filter(|(_, (found, _))| *found > 0)
8155        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
8156        .collect();
8157    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8158    let parts: Vec<String> = pairs
8159        .iter()
8160        .map(|(lang, pct)| {
8161            let name = lang.replace('"', "\\\"");
8162            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
8163        })
8164        .collect();
8165    format!("[{}]", parts.join(","))
8166}
8167
8168fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
8169    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
8170    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
8171}
8172
8173fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
8174    let mut entry = build_test_scope_entry(run);
8175    if !run.submodule_summaries.is_empty() {
8176        let subs: serde_json::Map<String, serde_json::Value> = run
8177            .submodule_summaries
8178            .iter()
8179            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
8180            .collect();
8181        entry["submodules"] = serde_json::Value::Object(subs);
8182    }
8183    entry
8184}
8185
8186fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
8187    let name = l.language.display_name().replace('"', "\\\"");
8188    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
8189    let density = if l.code_lines > 0 {
8190        l.test_count as f64 / l.code_lines as f64 * 1000.0
8191    } else {
8192        0.0
8193    };
8194    format!(
8195        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
8196        name = name,
8197        t = l.test_count,
8198        a = l.test_assertion_count,
8199        s = l.test_suite_count,
8200        c = l.code_lines,
8201        d = density,
8202        f = l.files,
8203    )
8204}
8205
8206fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
8207    let Some(r) = run else {
8208        return "[]".to_string();
8209    };
8210    let mut langs: Vec<&sloc_core::LanguageSummary> = r
8211        .totals_by_language
8212        .iter()
8213        .filter(|l| l.test_count > 0)
8214        .collect();
8215    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
8216    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
8217    format!("[{}]", parts.join(","))
8218}
8219
8220// GET /test-metrics
8221#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
8222#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
8223async fn test_metrics_handler(
8224    State(state): State<AppState>,
8225    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8226) -> Response {
8227    auto_scan_watched_dirs(&state).await;
8228    let watched_dirs_list: Vec<String> = {
8229        let wd = state.watched_dirs.lock().await;
8230        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8231    };
8232    let latest_run: Option<AnalysisRun> = {
8233        let reg = state.registry.lock().await;
8234        let json_str: Option<String> = reg
8235            .entries
8236            .first()
8237            .and_then(|e| e.json_path.as_ref())
8238            .and_then(|p| std::fs::read_to_string(p).ok());
8239        drop(reg);
8240        json_str
8241            .as_deref()
8242            .and_then(|s| serde_json::from_str(s).ok())
8243    };
8244
8245    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
8246    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
8247
8248    // Build coverage chart JSON (per-language avg line coverage %).
8249    let cov_json: String = latest_run
8250        .as_ref()
8251        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8252        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
8253
8254    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
8255    let _cov_tier_json: String = latest_run
8256        .as_ref()
8257        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
8258        .map_or_else(
8259            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
8260            compute_cov_tier_json_str,
8261        );
8262
8263    let total_tests: u64 = latest_run
8264        .as_ref()
8265        .map_or(0, |r| r.summary_totals.test_count);
8266    let total_assertions: u64 = latest_run
8267        .as_ref()
8268        .map_or(0, |r| r.summary_totals.test_assertion_count);
8269    let total_suites: u64 = latest_run
8270        .as_ref()
8271        .map_or(0, |r| r.summary_totals.test_suite_count);
8272    let total_code: u64 = latest_run
8273        .as_ref()
8274        .map_or(0, |r| r.summary_totals.code_lines);
8275    let workspace_density: f64 = if total_code > 0 {
8276        total_tests as f64 / total_code as f64 * 1000.0
8277    } else {
8278        0.0
8279    };
8280    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
8281        r.totals_by_language
8282            .iter()
8283            .filter(|l| l.test_count > 0)
8284            .count()
8285    });
8286    let most_tested: String = latest_run
8287        .as_ref()
8288        .and_then(|r| {
8289            r.totals_by_language
8290                .iter()
8291                .filter(|l| l.test_count > 0)
8292                .max_by_key(|l| l.test_count)
8293        })
8294        .map_or_else(
8295            || "\u{2014}".to_string(),
8296            |l| l.language.display_name().to_string(),
8297        );
8298    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
8299        r.per_file_records
8300            .iter()
8301            .filter(|f| f.raw_line_categories.test_count > 0)
8302            .count() as u64
8303    });
8304    let total_files_analyzed: u64 = latest_run
8305        .as_ref()
8306        .map_or(0, |r| r.summary_totals.files_analyzed);
8307    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
8308
8309    // Aggregated coverage percentages from summary_totals
8310    let cov_line_pct_str: String = latest_run
8311        .as_ref()
8312        .filter(|r| r.summary_totals.coverage_lines_found > 0)
8313        .map_or_else(
8314            || "0".to_string(),
8315            |r| {
8316                format!(
8317                    "{:.1}",
8318                    r.summary_totals.coverage_lines_hit as f64
8319                        / r.summary_totals.coverage_lines_found as f64
8320                        * 100.0
8321                )
8322            },
8323        );
8324    let cov_fn_pct_str: String = latest_run
8325        .as_ref()
8326        .filter(|r| r.summary_totals.coverage_functions_found > 0)
8327        .map_or_else(
8328            || "0".to_string(),
8329            |r| {
8330                format!(
8331                    "{:.1}",
8332                    r.summary_totals.coverage_functions_hit as f64
8333                        / r.summary_totals.coverage_functions_found as f64
8334                        * 100.0
8335                )
8336            },
8337        );
8338    let cov_branch_pct_str: String = latest_run
8339        .as_ref()
8340        .filter(|r| r.summary_totals.coverage_branches_found > 0)
8341        .map_or_else(
8342            || "0".to_string(),
8343            |r| {
8344                format!(
8345                    "{:.1}",
8346                    r.summary_totals.coverage_branches_hit as f64
8347                        / r.summary_totals.coverage_branches_found as f64
8348                        * 100.0
8349                )
8350            },
8351        );
8352
8353    let cov_no_data_notice = if has_coverage {
8354        String::new()
8355    } else {
8356        String::from(
8357            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
8358<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>
8359<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
8360  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
8361  <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>
8362  <span style="color:var(--muted);font-size:12px;">&middot;</span>
8363  <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>
8364  <span style="color:var(--muted);font-size:12px;">&middot;</span>
8365  <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>
8366</div>
8367<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
8368</div>"#,
8369        )
8370    };
8371
8372    let workspace_density_str = format!("{workspace_density:.1}");
8373    let nonce = &csp_nonce;
8374    let version = env!("CARGO_PKG_VERSION");
8375
8376    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
8377    // of interactive controls — folder watching is managed by the host administrator.
8378    let watched_dirs_html: String = if state.server_mode {
8379        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()
8380    } else {
8381        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
8382            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
8383                .to_string()
8384        } else {
8385            watched_dirs_list
8386                .iter()
8387                .fold(String::new(), |mut s, d| {
8388                    use std::fmt::Write as _;
8389                    let escaped =
8390                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
8391                    write!(
8392                        s,
8393                        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>"#
8394                    ).expect("write to String is infallible");
8395                    s
8396                })
8397        };
8398        format!(
8399            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>"#
8400        )
8401    };
8402
8403    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
8404    let scope_data_json: String = {
8405        let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
8406        scope_map.insert(
8407            "__all__".to_string(),
8408            latest_run.as_ref().map_or_else(
8409                || {
8410                    serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
8411                        "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
8412                        "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
8413                        "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
8414                        "has_coverage":false,"submodules":{}})
8415                },
8416                build_test_scope_entry,
8417            ),
8418        );
8419        let all_roots: Vec<String> = {
8420            let reg = state.registry.lock().await;
8421            let mut seen = std::collections::BTreeSet::new();
8422            reg.entries
8423                .iter()
8424                .flat_map(|e| e.input_roots.iter().cloned())
8425                .filter(|r| seen.insert(r.clone()))
8426                .collect()
8427        };
8428        for root in &all_roots {
8429            let run_for_root: Option<AnalysisRun> = {
8430                let reg = state.registry.lock().await;
8431                let json_str = reg
8432                    .entries
8433                    .iter()
8434                    .find(|e| e.input_roots.iter().any(|r| r == root))
8435                    .and_then(|e| e.json_path.as_ref())
8436                    .and_then(|p| std::fs::read_to_string(p).ok());
8437                drop(reg);
8438                json_str
8439                    .as_deref()
8440                    .and_then(|s| serde_json::from_str(s).ok())
8441            };
8442            if let Some(ref run) = run_for_root {
8443                scope_map.insert(root.clone(), build_scope_entry_for_run(run));
8444            }
8445        }
8446        serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
8447    };
8448
8449    let html = format!(
8450        r#"<!doctype html>
8451<html lang="en">
8452<head>
8453  <meta charset="utf-8" />
8454  <meta name="viewport" content="width=device-width, initial-scale=1" />
8455  <title>OxideSLOC | Test Metrics</title>
8456  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8457  <style nonce="{nonce}">
8458    :root {{
8459      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
8460      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8461      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
8462      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8463      --info-bg:#eef3ff; --info-text:#4467d8;
8464    }}
8465    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
8466    *{{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;}}
8467    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8468    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
8469    .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;}}
8470    @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));}}}}
8471    .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);}}
8472    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
8473    .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));}}
8474    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8475    .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;}}
8476    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
8477    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
8478    @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; }} }}
8479    .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;}}
8480    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8481    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8482    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8483    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
8484    .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;}}
8485    .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;}}
8486    .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;}}
8487    .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;}}
8488    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8489    .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);}}
8490    .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;}}
8491    .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;}}
8492    .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;}}
8493    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8494    .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;}}
8495    .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);}}
8496    .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;}}
8497    .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;}}
8498    .tz-select:focus{{border-color:var(--oxide);}}
8499    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8500    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
8501    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
8502    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
8503    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
8504    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
8505    .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;}}
8506    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
8507    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
8508    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
8509    .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;}}
8510    .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;}}
8511    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
8512    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
8513    .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);}}
8514    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
8515    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
8516    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
8517    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
8518    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
8519    .chart-canvas-wrap{{position:relative;height:280px;}}
8520    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8521    .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;}}
8522    .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;}}
8523    .data-table tr:last-child td{{border-bottom:none;}}
8524    .data-table tbody tr:hover td{{background:var(--surface-2);}}
8525    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
8526    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
8527    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
8528    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
8529    .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;}}
8530    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
8531    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
8532    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
8533    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
8534    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
8535    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
8536    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
8537    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
8538    .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;}}
8539    .chart-select:focus{{border-color:var(--accent);}}
8540    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
8541    .trend-canvas-wrap{{position:relative;height:260px;}}
8542    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
8543    .site-footer a{{color:var(--muted);}}
8544    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
8545    .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;}}
8546    .btn:hover{{background:var(--surface-2);}}
8547    .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;}}
8548    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8549    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
8550    .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;}}
8551    .scope-sel:focus{{border-color:var(--accent);}}
8552    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
8553    .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;}}
8554    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
8555    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
8556    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
8557    .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;}}
8558    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
8559    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
8560    .watched-chip-rm:hover{{color:var(--oxide);}}
8561    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
8562    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
8563    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
8564    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
8565    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
8566    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
8567    .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;}}
8568    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
8569    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
8570    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
8571    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
8572    .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;}}
8573    .cov-file-search:focus{{border-color:var(--accent);}}
8574    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
8575    .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;}}
8576    body.dark-theme .cov-file-search{{background:var(--surface);}}
8577  </style>
8578</head>
8579<body>
8580  <div class="background-watermarks" aria-hidden="true">
8581    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8582    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8583    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8584    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8585    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8586    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8587  </div>
8588  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8589  <div class="top-nav">
8590    <div class="top-nav-inner">
8591      <a class="brand" href="/">
8592        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8593        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
8594      </a>
8595      <div class="nav-right">
8596        <a class="nav-pill" href="/">Home</a>
8597        <div class="nav-dropdown">
8598          <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>
8599          <div class="nav-dropdown-menu">
8600            <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>
8601          </div>
8602        </div>
8603        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8604        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
8605        <div class="nav-dropdown">
8606          <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>
8607          <div class="nav-dropdown-menu">
8608            <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>
8609          </div>
8610        </div>
8611        <div class="server-status-wrap" id="server-status-wrap">
8612          <div class="nav-pill server-online-pill" id="server-status-pill">
8613            <span class="status-dot" id="status-dot"></span>
8614            <span id="server-status-label">Server</span>
8615            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
8616          </div>
8617          <div class="server-status-tip">
8618            OxideSLOC is running — accessible on your network.
8619            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
8620          </div>
8621        </div>
8622        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
8623          <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>
8624        </button>
8625        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8626          <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>
8627          <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>
8628        </button>
8629      </div>
8630    </div>
8631  </div>
8632
8633  <div class="page">
8634    {watched_dirs_html}
8635    <div class="scope-bar">
8636      <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>
8637      <span class="scope-label">Scope</span>
8638      <div class="scope-sel-wrap">
8639        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
8640        <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);">
8641          <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>
8642          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
8643        </div>
8644      </div>
8645    </div>
8646    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8647      <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>
8648      <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>
8649      <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>
8650      <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>
8651    </div>
8652    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
8653      <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>
8654      <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>
8655      <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>
8656      <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>
8657    </div>
8658
8659    <div class="panel">
8660      <h1>Test Metrics</h1>
8661      <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>
8662
8663      <div class="chart-row">
8664        <div class="chart-box">
8665          <div class="chart-box-title">Test Definitions by Language</div>
8666          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
8667        </div>
8668        <div class="chart-box">
8669          <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
8670          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
8671        </div>
8672      </div>
8673
8674      <div class="section-header">Language Breakdown</div>
8675      {cov_no_data_notice}
8676      <div style="overflow-x:auto;">
8677        <table class="data-table" id="lang-table">
8678          <thead><tr>
8679            <th>Language</th>
8680            <th class="num">Test Fns</th>
8681            <th class="num">Assertions</th>
8682            <th class="num">Suites</th>
8683            <th class="num">Code Lines</th>
8684            <th class="num">Files</th>
8685            <th class="num">Density / 1K</th>
8686            <th>Relative Density</th>
8687          </tr></thead>
8688          <tbody id="lang-tbody"></tbody>
8689        </table>
8690      </div>
8691    </div>
8692
8693    <div class="panel" id="cov-panel" style="display:none;">
8694      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
8695      <div class="cov-gauge-row" id="cov-gauges">
8696        <div class="cov-gauge-card">
8697          <div class="cov-gauge-label">Line Coverage</div>
8698          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
8699          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
8700          <div class="cov-gauge-sub">Lines hit / instrumented</div>
8701        </div>
8702        <div class="cov-gauge-card">
8703          <div class="cov-gauge-label">Function Coverage</div>
8704          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
8705          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
8706          <div class="cov-gauge-sub">Functions hit / found</div>
8707        </div>
8708        <div class="cov-gauge-card">
8709          <div class="cov-gauge-label">Branch Coverage</div>
8710          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
8711          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
8712          <div class="cov-gauge-sub">Branches hit / found</div>
8713        </div>
8714      </div>
8715      <div class="chart-row">
8716        <div class="chart-box">
8717          <div class="chart-box-title">Line Coverage % by Language</div>
8718          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
8719        </div>
8720        <div class="chart-box">
8721          <div class="chart-box-title">Coverage Tier Distribution</div>
8722          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
8723        </div>
8724      </div>
8725
8726      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
8727      <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>
8728      <div class="cov-file-toolbar">
8729        <div class="cov-filter-tabs" id="cov-filter-tabs">
8730          <button class="cov-tab active" data-tier="all">All</button>
8731          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
8732          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
8733          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
8734          <button class="cov-tab" data-tier="high">High (≥80%)</button>
8735        </div>
8736        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
8737      </div>
8738      <div style="overflow-x:auto;">
8739        <table class="data-table" id="cov-file-table">
8740          <thead><tr>
8741            <th>File</th>
8742            <th>Lang</th>
8743            <th class="num">Line %</th>
8744            <th class="num">Lines Hit / Found</th>
8745            <th class="num">Fn %</th>
8746            <th class="num">Fns Hit / Found</th>
8747          </tr></thead>
8748          <tbody id="cov-file-tbody"></tbody>
8749        </table>
8750      </div>
8751      <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>
8752      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
8753    </div>
8754
8755    <div class="panel">
8756      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
8757      <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
8758      <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
8759      <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
8760    </div>
8761  </div>
8762
8763  <footer class="site-footer">
8764    local code analysis - metrics, history and reports
8765    &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>
8766    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8767    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8768    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8769    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
8770  </footer>
8771
8772  <script nonce="{nonce}">
8773  (function() {{
8774    // Theme
8775    var b = document.body;
8776    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
8777    var tgl = document.getElementById('theme-toggle');
8778    if (tgl) tgl.addEventListener('click', function() {{
8779      var d = b.classList.toggle('dark-theme');
8780      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
8781    }});
8782
8783    // Watermarks
8784    (function() {{
8785      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8786      if (!wms.length) return;
8787      var placed = [];
8788      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;}}
8789      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];}}
8790      var half=Math.floor(wms.length/2);
8791      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;}});
8792    }})();
8793
8794    // Code particles
8795    (function() {{
8796      var container = document.getElementById('code-particles');
8797      if (!container) return;
8798      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
8799      for (var i = 0; i < 36; i++) {{
8800        (function(idx) {{
8801          var el = document.createElement('span');
8802          el.className = 'code-particle';
8803          el.textContent = snippets[idx % snippets.length];
8804          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
8805          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
8806          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
8807          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';
8808          container.appendChild(el);
8809        }})(i);
8810      }}
8811    }})();
8812
8813    // Settings modal
8814    (function() {{
8815      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'}}];
8816      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);}});}}
8817      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
8818      var btn=document.getElementById('settings-btn');if(!btn)return;
8819      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
8820      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>';
8821      document.body.appendChild(m);
8822      var g=document.getElementById('scheme-grid');
8823      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);}});
8824      var cl=document.getElementById('settings-close');
8825      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');}});
8826      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
8827      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
8828    }})();
8829
8830    // Watched folder picker
8831    (function() {{
8832      var btn = document.getElementById('add-watched-btn');
8833      if (!btn) return;
8834      btn.addEventListener('click', function() {{
8835        fetch('/pick-directory?kind=reports')
8836          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
8837          .then(function(data) {{
8838            if (!data.cancelled && data.selected_path) {{
8839              var form = document.createElement('form');
8840              form.method = 'POST';
8841              form.action = '/watched-dirs/add';
8842              var ri = document.createElement('input');
8843              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
8844              var fi = document.createElement('input');
8845              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
8846              form.appendChild(ri); form.appendChild(fi);
8847              document.body.appendChild(form);
8848              form.submit();
8849            }}
8850          }})
8851          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
8852      }});
8853    }})();
8854  }})();
8855  </script>
8856
8857  <script src="/static/chart.js" nonce="{nonce}"></script>
8858  <script nonce="{nonce}">
8859  (function() {{
8860    var SCOPE_DATA = {scope_data_json};
8861    var currentRoot = '__all__';
8862    var currentSub  = '';
8863    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
8864    var ALL_CHARTS = [];
8865
8866    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();}}
8867    function fmtFull(n){{return Number(n).toLocaleString();}}
8868    function isDark(){{return document.body.classList.contains('dark-theme');}}
8869    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
8870    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
8871    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
8872
8873    function getDataset() {{
8874      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
8875      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
8876      return r;
8877    }}
8878    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
8879
8880    function renderTestCharts(D) {{
8881      testsChart = destroyChart(testsChart);
8882      densityChart = destroyChart(densityChart);
8883      if (!D || !D.length) return;
8884      var top15 = D.slice(0, 15);
8885      var canvas1 = document.getElementById('canvas-tests');
8886      if (canvas1) {{
8887        testsChart = new Chart(canvas1, {{
8888          type: 'bar',
8889          data: {{
8890            labels: top15.map(function(d){{ return d.lang; }}),
8891            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
8892          }},
8893          options: {{
8894            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8895            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
8896            scales: {{
8897              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
8898              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8899            }}
8900          }}
8901        }});
8902        ALL_CHARTS.push(testsChart);
8903      }}
8904      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
8905      var canvas2 = document.getElementById('canvas-density');
8906      if (canvas2) {{
8907        densityChart = new Chart(canvas2, {{
8908          type: 'bar',
8909          data: {{
8910            labels: topD.map(function(d){{ return d.lang; }}),
8911            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 }}]
8912          }},
8913          options: {{
8914            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8915            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
8916            scales: {{
8917              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
8918              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8919            }}
8920          }}
8921        }});
8922        ALL_CHARTS.push(densityChart);
8923      }}
8924    }}
8925
8926    function renderCovCharts(covD, tiers) {{
8927      covChart = destroyChart(covChart);
8928      tierChart = destroyChart(tierChart);
8929      var covCanvas = document.getElementById('canvas-cov');
8930      if (covCanvas && covD && covD.length) {{
8931        covChart = new Chart(covCanvas, {{
8932          type: 'bar',
8933          data: {{
8934            labels: covD.map(function(d){{ return d.lang; }}),
8935            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 }}]
8936          }},
8937          options: {{
8938            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
8939            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
8940            scales: {{
8941              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
8942              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
8943            }}
8944          }}
8945        }});
8946        ALL_CHARTS.push(covChart);
8947      }}
8948      var tierCanvas = document.getElementById('canvas-cov-tiers');
8949      if (tierCanvas && tiers) {{
8950        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
8951        tierChart = new Chart(tierCanvas, {{
8952          type: 'doughnut',
8953          data: {{
8954            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
8955            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
8956          }},
8957          options: {{
8958            responsive: true, maintainAspectRatio: false, cutout: '62%',
8959            plugins: {{
8960              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
8961              tooltip: {{ callbacks: {{ label: function(ctx) {{
8962                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
8963                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
8964              }} }} }}
8965            }}
8966          }}
8967        }});
8968        ALL_CHARTS.push(tierChart);
8969      }}
8970    }}
8971
8972    function buildLangTable(D) {{
8973      var tbody = document.getElementById('lang-tbody');
8974      if (!tbody) return;
8975      if (!D || !D.length) {{
8976        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>';
8977        return;
8978      }}
8979      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
8980      tbody.innerHTML = D.map(function(d) {{
8981        var barW = Math.round(d.density / maxDensity * 120);
8982        return '<tr>' +
8983          '<td><strong>' + d.lang + '</strong></td>' +
8984          '<td class="num">' + fmt(d.tests) + '</td>' +
8985          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
8986          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
8987          '<td class="num">' + fmt(d.code) + '</td>' +
8988          '<td class="num">' + fmt(d.files) + '</td>' +
8989          '<td class="num">' + d.density.toFixed(2) + '</td>' +
8990          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
8991          '</tr>';
8992      }}).join('');
8993    }}
8994
8995    var covFileData = [];
8996    var covFileTier = 'all';
8997    var covFileSearch = '';
8998
8999    function pctBadge(pct) {{
9000      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
9001      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
9002      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
9003    }}
9004
9005    function buildCovFileTable() {{
9006      var tbody = document.getElementById('cov-file-tbody');
9007      var empty = document.getElementById('cov-file-empty');
9008      var count = document.getElementById('cov-file-count');
9009      if (!tbody) return;
9010      var srch = covFileSearch.toLowerCase();
9011      var filtered = covFileData.filter(function(f) {{
9012        if (covFileTier === 'zero' && f.line_pct > 0) return false;
9013        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
9014        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
9015        if (covFileTier === 'high' && f.line_pct < 80) return false;
9016        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
9017        return true;
9018      }});
9019      if (!filtered.length) {{
9020        tbody.innerHTML = '';
9021        if (empty) empty.style.display = '';
9022        if (count) count.textContent = '';
9023        return;
9024      }}
9025      if (empty) empty.style.display = 'none';
9026      var shown = Math.min(filtered.length, 500);
9027      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
9028      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
9029        var fnCol = f.fn_pct < 0
9030          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
9031          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
9032        return '<tr>' +
9033          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
9034          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
9035          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
9036          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
9037          fnCol +
9038          '</tr>';
9039      }}).join('');
9040    }}
9041
9042    (function() {{
9043      var tabs = document.getElementById('cov-filter-tabs');
9044      if (tabs) {{
9045        tabs.addEventListener('click', function(e) {{
9046          var btn = e.target.closest('.cov-tab');
9047          if (!btn) return;
9048          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
9049          btn.classList.add('active');
9050          covFileTier = btn.getAttribute('data-tier');
9051          buildCovFileTable();
9052        }});
9053      }}
9054      var srch = document.getElementById('cov-file-search');
9055      if (srch) {{
9056        srch.addEventListener('input', function() {{
9057          covFileSearch = this.value;
9058          buildCovFileTable();
9059        }});
9060      }}
9061    }})();
9062
9063    function updateCovGauges(t) {{
9064      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
9065      var el;
9066      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
9067      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
9068      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
9069      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
9070      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
9071      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
9072    }}
9073
9074    function applyScope() {{
9075      var d = getDataset();
9076      var t = d.totals;
9077      var el;
9078      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
9079      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
9080      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
9081      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
9082      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
9083      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
9084      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
9085      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
9086      renderTestCharts(d.lang_tests);
9087      buildLangTable(d.lang_tests);
9088      var covPanel = document.getElementById('cov-panel');
9089      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
9090      if (d.has_coverage) {{
9091        renderCovCharts(d.cov, d.cov_tiers);
9092        updateCovGauges(t);
9093        covFileData = d.file_cov || [];
9094        covFileTier = 'all';
9095        covFileSearch = '';
9096        var tabs = document.getElementById('cov-filter-tabs');
9097        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
9098        var srch = document.getElementById('cov-file-search');
9099        if (srch) srch.value = '';
9100        buildCovFileTable();
9101      }}
9102      loadTrend();
9103    }}
9104
9105    // Populate scope-root-sel from SCOPE_DATA keys
9106    (function() {{
9107      var sel = document.getElementById('scope-root-sel');
9108      if (!sel) return;
9109      Object.keys(SCOPE_DATA).forEach(function(k) {{
9110        if (k === '__all__') return;
9111        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
9112      }});
9113    }})();
9114
9115    document.getElementById('scope-root-sel').addEventListener('change', function() {{
9116      currentRoot = this.value;
9117      currentSub = '';
9118      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
9119      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
9120      var subWrap = document.getElementById('scope-sub-wrap');
9121      var subSel  = document.getElementById('scope-sub-sel');
9122      subSel.innerHTML = '<option value="">Entire project</option>';
9123      if (subNames.length) {{
9124        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
9125        subWrap.style.display = 'flex';
9126      }} else {{
9127        subWrap.style.display = 'none';
9128      }}
9129      applyScope();
9130    }});
9131
9132    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
9133      currentSub = this.value;
9134      applyScope();
9135    }});
9136
9137    function buildTrend(data) {{
9138      var trendCanvas = document.getElementById('canvas-trend');
9139      var trendEmpty  = document.getElementById('trend-empty');
9140      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
9141      pts = pts.slice().reverse();
9142      if (!pts.length) {{
9143        if (trendCanvas) trendCanvas.style.display = 'none';
9144        if (trendEmpty) trendEmpty.style.display = '';
9145        return;
9146      }}
9147      if (trendCanvas) trendCanvas.style.display = '';
9148      if (trendEmpty) trendEmpty.style.display = 'none';
9149      trendChart = destroyChart(trendChart);
9150      if (!trendCanvas) return;
9151      trendChart = new Chart(trendCanvas, {{
9152        type: 'line',
9153        data: {{
9154          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
9155          datasets: [{{
9156            label: 'Test Definitions',
9157            data: pts.map(function(d){{ return d.test_count; }}),
9158            borderColor: '#C45C10',
9159            backgroundColor: 'rgba(196,92,16,0.10)',
9160            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
9161            pointRadius: 5, fill: true, tension: 0.3
9162          }}]
9163        }},
9164        options: {{
9165          responsive: true, maintainAspectRatio: false,
9166          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
9167          scales: {{
9168            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
9169            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
9170          }}
9171        }}
9172      }});
9173      ALL_CHARTS.push(trendChart);
9174    }}
9175
9176    function loadTrend() {{
9177      var url = '/api/metrics/history?limit=100';
9178      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
9179      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
9180        buildTrend(data);
9181      }}).catch(function(){{
9182        var trendEmpty = document.getElementById('trend-empty');
9183        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
9184      }});
9185    }}
9186
9187    // Re-render charts on theme toggle
9188    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
9189      setTimeout(function() {{
9190        ALL_CHARTS.forEach(function(c) {{
9191          if (c && c.options && c.options.scales) {{
9192            Object.values(c.options.scales).forEach(function(ax) {{
9193              if (ax.grid) ax.grid.color = clr();
9194              if (ax.ticks) ax.ticks.color = txtClr();
9195            }});
9196            c.update();
9197          }}
9198        }});
9199      }}, 80);
9200    }});
9201
9202    applyScope();
9203  }})();
9204  </script>
9205  <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>
9206</body>
9207</html>"#,
9208    );
9209    Html(html).into_response()
9210}
9211
9212// ── Embeddable widget ─────────────────────────────────────────────────────────
9213// Protected. Returns a self-contained HTML page suitable for iframing inside
9214// Jenkins build summaries, Confluence iframe macros, or Jira panels.
9215//
9216// GET /embed/summary?run_id=<uuid>&theme=dark
9217
9218#[derive(Deserialize)]
9219struct EmbedQuery {
9220    run_id: Option<String>,
9221    theme: Option<String>,
9222}
9223
9224async fn embed_handler(
9225    State(state): State<AppState>,
9226    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9227    Query(query): Query<EmbedQuery>,
9228) -> Response {
9229    let entry = {
9230        let reg = state.registry.lock().await;
9231        query.run_id.as_ref().map_or_else(
9232            || reg.entries.first().cloned(),
9233            |id| reg.find_by_run_id(id).cloned(),
9234        )
9235    };
9236
9237    let Some(entry) = entry else {
9238        return Html(
9239            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
9240                .to_string(),
9241        )
9242        .into_response();
9243    };
9244
9245    let dark = query.theme.as_deref() == Some("dark");
9246    let languages: Vec<(String, u64, u64)> = entry
9247        .json_path
9248        .as_ref()
9249        .and_then(|p| read_json(p).ok())
9250        .map(|run| {
9251            run.totals_by_language
9252                .iter()
9253                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
9254                .collect()
9255        })
9256        .unwrap_or_default();
9257
9258    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
9259}
9260
9261fn render_embed_widget(
9262    entry: &RegistryEntry,
9263    languages: &[(String, u64, u64)],
9264    dark: bool,
9265    csp_nonce: &str,
9266) -> String {
9267    let s = &entry.summary;
9268    let total = s.code_lines + s.comment_lines + s.blank_lines;
9269    let code_pct = s
9270        .code_lines
9271        .checked_mul(100)
9272        .and_then(|n| n.checked_div(total))
9273        .unwrap_or(0);
9274
9275    let (bg, fg, surface, muted, border) = if dark {
9276        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
9277    } else {
9278        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
9279    };
9280
9281    let mut lang_rows = String::new();
9282    for (name, files, code) in languages {
9283        write!(
9284            lang_rows,
9285            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
9286            escape_html(name),
9287            format_number(*files),
9288            format_number(*code),
9289        )
9290        .ok();
9291    }
9292
9293    let lang_table = if lang_rows.is_empty() {
9294        String::new()
9295    } else {
9296        format!(
9297            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
9298        )
9299    };
9300
9301    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
9302    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
9303    let project_esc = escape_html(&entry.project_label);
9304    let code_lines = format_number(s.code_lines);
9305    let comment_lines = format_number(s.comment_lines);
9306    let files = format_number(s.files_analyzed);
9307    let code_raw = s.code_lines;
9308    let comment_raw = s.comment_lines;
9309    let blank_raw = s.blank_lines;
9310
9311    format!(
9312        r#"<!doctype html>
9313<html lang="en">
9314<head>
9315  <meta charset="utf-8">
9316  <meta name="viewport" content="width=device-width,initial-scale=1">
9317  <title>OxideSLOC &mdash; {project_esc}</title>
9318  <script src="/static/chart.js"></script>
9319  <style nonce="{csp_nonce}">
9320    *{{box-sizing:border-box;margin:0;padding:0}}
9321    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
9322    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
9323    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
9324    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
9325    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
9326    .card .v{{font-size:18px;font-weight:700}}
9327    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
9328    .row{{display:flex;gap:12px;align-items:flex-start}}
9329    .pie{{width:120px;height:120px;flex-shrink:0}}
9330    .lt{{border-collapse:collapse;width:100%;flex:1}}
9331    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
9332    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
9333    .n{{text-align:right}}
9334    .footer{{margin-top:10px;color:{muted};font-size:10px}}
9335  </style>
9336</head>
9337<body>
9338  <h2>{project_esc}</h2>
9339  <div class="sub">{timestamp} &middot; run {run_short}</div>
9340  <div class="cards">
9341    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
9342    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
9343    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
9344    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
9345  </div>
9346  <div class="row">
9347    <canvas class="pie" id="c"></canvas>
9348    {lang_table}
9349  </div>
9350  <div class="footer">oxide-sloc</div>
9351  <script nonce="{csp_nonce}">
9352    new Chart(document.getElementById('c'),{{
9353      type:'doughnut',
9354      data:{{
9355        labels:['Code','Comments','Blank'],
9356        datasets:[{{
9357          data:[{code_raw},{comment_raw},{blank_raw}],
9358          backgroundColor:['#4a78ee','#b35428','#aaa'],
9359          borderWidth:0
9360        }}]
9361      }},
9362      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
9363    }});
9364  </script>
9365</body>
9366</html>"#
9367    )
9368}
9369
9370#[allow(clippy::too_many_arguments)]
9371fn persist_run_artifacts(
9372    run: &sloc_core::AnalysisRun,
9373    report_html: &str,
9374    run_dir: &Path,
9375    generate_json: bool,
9376    generate_html: bool,
9377    generate_pdf: bool,
9378    report_title: &str,
9379    file_stem: &str,
9380    result_context: RunResultContext,
9381) -> Result<(RunArtifacts, PendingPdf)> {
9382    fs::create_dir_all(run_dir)
9383        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
9384
9385    let mut html_path = None;
9386    let mut pdf_path = None;
9387    let mut json_path = None;
9388    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
9389
9390    if generate_html {
9391        let path = run_dir.join(format!("report_{file_stem}.html"));
9392        fs::write(&path, report_html)
9393            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
9394        html_path = Some(path);
9395    }
9396
9397    if generate_json {
9398        let path = run_dir.join(format!("result_{file_stem}.json"));
9399        let json = serde_json::to_string_pretty(run)
9400            .context("failed to serialize analysis run to JSON")?;
9401        fs::write(&path, json)
9402            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
9403        json_path = Some(path);
9404    }
9405
9406    if generate_pdf {
9407        let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
9408
9409        // Attempt pure-Rust native PDF (zero external dependencies).
9410        // Falls back to the HTML→browser background task on failure.
9411        match write_pdf_from_run(run, &pdf_dest) {
9412            Ok(()) => {
9413                eprintln!(
9414                    "[oxide-sloc][pdf] native PDF written to {}",
9415                    pdf_dest.display()
9416                );
9417                pdf_path = Some(pdf_dest);
9418                // pending_pdf stays None — no background browser task needed.
9419            }
9420            Err(native_err) => {
9421                eprintln!(
9422                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), \
9423                     scheduling HTML→browser fallback"
9424                );
9425                let source_html_path = if let Some(existing) = html_path.as_ref() {
9426                    existing.clone()
9427                } else {
9428                    let temp_html = run_dir.join("_report_rendered.html");
9429                    fs::write(&temp_html, report_html).with_context(|| {
9430                        format!(
9431                            "failed to write temporary HTML report to {}",
9432                            temp_html.display()
9433                        )
9434                    })?;
9435                    temp_html
9436                };
9437                let cleanup_src = !generate_html;
9438                pdf_path = Some(pdf_dest.clone());
9439                pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
9440            }
9441        }
9442    }
9443
9444    // CSV and XLSX are always generated (like JSON) — no extra flag required.
9445    let csv_path = {
9446        let path = run_dir.join(format!("report_{file_stem}.csv"));
9447        if let Err(e) = sloc_report::write_csv(run, &path) {
9448            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
9449            None
9450        } else {
9451            Some(path)
9452        }
9453    };
9454
9455    let xlsx_path = {
9456        let path = run_dir.join(format!("report_{file_stem}.xlsx"));
9457        if let Err(e) = sloc_report::write_xlsx(run, &path) {
9458            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
9459            None
9460        } else {
9461            Some(path)
9462        }
9463    };
9464
9465    let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
9466
9467    Ok((
9468        RunArtifacts {
9469            output_dir: run_dir.to_path_buf(),
9470            html_path,
9471            pdf_path,
9472            json_path,
9473            csv_path,
9474            xlsx_path,
9475            scan_config_path,
9476            report_title: report_title.to_string(),
9477            result_context,
9478        },
9479        pending_pdf,
9480    ))
9481}
9482
9483/// Find a scan-config JSON file in `dir`, checking both the legacy fixed name and
9484/// the current `scan-config_<stem>.json` pattern for backwards compatibility.
9485fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
9486    let exact = dir.join("scan-config.json");
9487    if exact.exists() {
9488        return Some(exact);
9489    }
9490    fs::read_dir(dir).ok().and_then(|entries| {
9491        entries
9492            .filter_map(std::result::Result::ok)
9493            .find(|e| {
9494                let name = e.file_name();
9495                let name = name.to_string_lossy();
9496                name.starts_with("scan-config") && name.ends_with(".json")
9497            })
9498            .map(|e| e.path())
9499    })
9500}
9501
9502// ── Config export / import ────────────────────────────────────────────────────
9503
9504async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
9505    let toml_str = match toml::to_string_pretty(&state.base_config) {
9506        Ok(s) => s,
9507        Err(e) => {
9508            return (
9509                StatusCode::INTERNAL_SERVER_ERROR,
9510                format!("serialization error: {e}"),
9511            )
9512                .into_response();
9513        }
9514    };
9515    (
9516        [
9517            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
9518            (
9519                header::CONTENT_DISPOSITION,
9520                "attachment; filename=\".oxide-sloc.toml\"",
9521            ),
9522        ],
9523        toml_str,
9524    )
9525        .into_response()
9526}
9527
9528#[derive(Serialize)]
9529struct OkResponse {
9530    ok: bool,
9531}
9532
9533#[derive(Serialize)]
9534struct SaveProfileResponse {
9535    ok: bool,
9536    id: String,
9537}
9538
9539#[derive(Serialize)]
9540struct ProfileListResponse {
9541    profiles: Vec<ScanProfile>,
9542}
9543
9544#[derive(Serialize)]
9545struct ImportConfigResponse {
9546    ok: bool,
9547    config: sloc_config::AppConfig,
9548}
9549
9550#[derive(Deserialize)]
9551struct ImportConfigBody {
9552    toml: String,
9553}
9554
9555async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
9556    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
9557        Ok(config) => {
9558            if let Err(e) = config.validate() {
9559                return error::unprocessable_entity(&e.to_string());
9560            }
9561            Json(ImportConfigResponse { ok: true, config }).into_response()
9562        }
9563        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
9564    }
9565}
9566
9567// ── Scan profiles API ─────────────────────────────────────────────────────────
9568
9569async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
9570    let store = state.scan_profiles.lock().await;
9571    Json(ProfileListResponse {
9572        profiles: store.profiles.clone(),
9573    })
9574}
9575
9576#[derive(Deserialize)]
9577struct SaveScanProfileBody {
9578    name: String,
9579    params: serde_json::Value,
9580}
9581
9582async fn api_save_scan_profile(
9583    State(state): State<AppState>,
9584    Json(body): Json<SaveScanProfileBody>,
9585) -> impl IntoResponse {
9586    if body.name.trim().is_empty() {
9587        return error::bad_request("name must not be empty");
9588    }
9589
9590    let id = uuid::Uuid::new_v4().to_string();
9591    let profile = ScanProfile {
9592        id: id.clone(),
9593        name: body.name.trim().to_string(),
9594        created_at: chrono::Utc::now().to_rfc3339(),
9595        params: body.params,
9596    };
9597
9598    let mut store = state.scan_profiles.lock().await;
9599    store.profiles.push(profile);
9600    if let Err(e) = store.save(&state.scan_profiles_path) {
9601        tracing::warn!("failed to persist scan profiles: {e}");
9602    }
9603    drop(store);
9604
9605    (
9606        StatusCode::CREATED,
9607        Json(SaveProfileResponse { ok: true, id }),
9608    )
9609        .into_response()
9610}
9611
9612async fn api_delete_scan_profile(
9613    State(state): State<AppState>,
9614    AxumPath(id): AxumPath<String>,
9615) -> impl IntoResponse {
9616    let mut store = state.scan_profiles.lock().await;
9617    let before = store.profiles.len();
9618    store.profiles.retain(|p| p.id != id);
9619    if store.profiles.len() == before {
9620        drop(store);
9621        return error::not_found("profile not found");
9622    }
9623    if let Err(e) = store.save(&state.scan_profiles_path) {
9624        tracing::warn!("failed to persist scan profiles: {e}");
9625    }
9626    drop(store);
9627    Json(OkResponse { ok: true }).into_response()
9628}
9629
9630fn resolve_output_root(raw: Option<&str>) -> PathBuf {
9631    let value = raw.unwrap_or("out/web").trim();
9632    let path = if value.is_empty() {
9633        PathBuf::from("out/web")
9634    } else {
9635        PathBuf::from(value)
9636    };
9637
9638    if path.is_absolute() {
9639        path
9640    } else {
9641        workspace_root().join(path)
9642    }
9643}
9644
9645/// Derive the directory that holds remote-repo clones from the output root.
9646fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
9647    std::env::var("SLOC_GIT_CLONES_DIR")
9648        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
9649}
9650
9651/// Build a deterministic filesystem path for a cloned remote repository.
9652/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
9653pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
9654    let safe: String = repo_url
9655        .chars()
9656        .map(|c| {
9657            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
9658                c
9659            } else {
9660                '_'
9661            }
9662        })
9663        .take(80)
9664        .collect();
9665    clones_dir.join(safe)
9666}
9667
9668/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
9669/// Runs synchronously — call from `tokio::task::spawn_blocking`.
9670pub(crate) fn scan_path_to_artifacts(
9671    scan_path: &Path,
9672    base_config: &AppConfig,
9673    label: &str,
9674) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
9675    let mut config = base_config.clone();
9676    config.discovery.root_paths = vec![scan_path.to_path_buf()];
9677    label.clone_into(&mut config.reporting.report_title);
9678    let run = analyze(&config, "git", None)?;
9679    let html = render_html(&run)?;
9680    let run_id = run.tool.run_id.clone();
9681    let project_label = sanitize_project_label(label);
9682    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9683    let file_stem = {
9684        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
9685        if commit.is_empty() {
9686            project_label
9687        } else {
9688            format!("{project_label}_{commit}")
9689        }
9690    };
9691    let (artifacts, _pending_pdf) = persist_run_artifacts(
9692        &run,
9693        &html,
9694        &output_dir,
9695        true,
9696        true,
9697        false,
9698        label,
9699        &file_stem,
9700        RunResultContext::default(),
9701    )?;
9702    Ok((run_id, artifacts, run))
9703}
9704
9705/// Re-spawn background poll tasks for any polling schedules saved to disk.
9706async fn restart_poll_schedules(state: &AppState) {
9707    let store = state.schedules.lock().await;
9708    let poll_schedules: Vec<_> = store
9709        .schedules
9710        .iter()
9711        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
9712        .cloned()
9713        .collect();
9714    drop(store);
9715    for schedule in poll_schedules {
9716        let interval = schedule.interval_secs.unwrap_or(300);
9717        let st = state.clone();
9718        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
9719    }
9720}
9721
9722fn split_patterns(raw: Option<&str>) -> Vec<String> {
9723    raw.unwrap_or("")
9724        .lines()
9725        .flat_map(|line| line.split(','))
9726        .map(str::trim)
9727        .filter(|part| !part.is_empty())
9728        .map(ToOwned::to_owned)
9729        .collect()
9730}
9731
9732fn build_sub_run(
9733    parent: &AnalysisRun,
9734    sub: &sloc_core::SubmoduleSummary,
9735    parent_path: &str,
9736) -> AnalysisRun {
9737    let sub_files: Vec<_> = parent
9738        .per_file_records
9739        .iter()
9740        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9741        .cloned()
9742        .collect();
9743    let mut config = parent.effective_configuration.clone();
9744    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
9745    AnalysisRun {
9746        tool: parent.tool.clone(),
9747        environment: parent.environment.clone(),
9748        effective_configuration: config,
9749        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
9750        summary_totals: SummaryTotals {
9751            files_considered: sub.files_analyzed,
9752            files_analyzed: sub.files_analyzed,
9753            files_skipped: 0,
9754            total_physical_lines: sub.total_physical_lines,
9755            code_lines: sub.code_lines,
9756            comment_lines: sub.comment_lines,
9757            blank_lines: sub.blank_lines,
9758            mixed_lines_separate: 0,
9759            functions: 0,
9760            classes: 0,
9761            variables: 0,
9762            imports: 0,
9763            test_count: 0,
9764            test_assertion_count: 0,
9765            test_suite_count: 0,
9766            coverage_lines_found: 0,
9767            coverage_lines_hit: 0,
9768            coverage_functions_found: 0,
9769            coverage_functions_hit: 0,
9770            coverage_branches_found: 0,
9771            coverage_branches_hit: 0,
9772        },
9773        totals_by_language: sub.language_summaries.clone(),
9774        per_file_records: sub_files,
9775        skipped_file_records: vec![],
9776        warnings: vec![],
9777        submodule_summaries: vec![],
9778        git_commit_short: parent.git_commit_short.clone(),
9779        git_commit_long: parent.git_commit_long.clone(),
9780        git_branch: parent.git_branch.clone(),
9781        git_commit_author: parent.git_commit_author.clone(),
9782        git_commit_date: parent.git_commit_date.clone(),
9783        git_tags: parent.git_tags.clone(),
9784        git_nearest_tag: parent.git_nearest_tag.clone(),
9785        git_remote_url: parent.git_remote_url.clone(),
9786    }
9787}
9788
9789pub(crate) fn sanitize_project_label(raw: &str) -> String {
9790    let candidate = Path::new(raw)
9791        .file_name()
9792        .and_then(|name| name.to_str())
9793        .unwrap_or("project");
9794
9795    let mut value = String::with_capacity(candidate.len());
9796    for ch in candidate.chars() {
9797        if ch.is_ascii_alphanumeric() {
9798            value.push(ch.to_ascii_lowercase());
9799        } else {
9800            value.push('-');
9801        }
9802    }
9803
9804    let compact = value.trim_matches('-').to_string();
9805    if compact.is_empty() {
9806        "project".to_string()
9807    } else {
9808        compact
9809    }
9810}
9811
9812/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
9813/// comparisons with non-canonicalized stored paths work correctly.
9814fn strip_unc_prefix(path: PathBuf) -> PathBuf {
9815    let s = path.to_string_lossy();
9816    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
9817        return PathBuf::from(format!(r"\\{rest}"));
9818    }
9819    if let Some(rest) = s.strip_prefix(r"\\?\") {
9820        return PathBuf::from(rest);
9821    }
9822    path
9823}
9824
9825/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
9826/// commit page URL for the most common hosting platforms.
9827fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
9828    let base = if let Some(rest) = remote.strip_prefix("git@") {
9829        let (host, path) = rest.split_once(':')?;
9830        format!("https://{}/{}", host, path.trim_end_matches(".git"))
9831    } else if remote.starts_with("https://") || remote.starts_with("http://") {
9832        remote
9833            .trim_end_matches('/')
9834            .trim_end_matches(".git")
9835            .to_owned()
9836    } else {
9837        return None;
9838    };
9839    let base = base.trim_end_matches('/');
9840    // GitLab uses /-/commit/; everything else uses /commit/
9841    if base.contains("gitlab.com") || base.contains("gitlab.") {
9842        Some(format!("{}/-/commit/{}", base, sha))
9843    } else if base.contains("bitbucket.org") {
9844        Some(format!("{}/commits/{}", base, sha))
9845    } else {
9846        Some(format!("{}/commit/{}", base, sha))
9847    }
9848}
9849
9850fn display_path(path: &Path) -> String {
9851    let s = path.to_string_lossy();
9852    // Strip Windows extended-length prefix for display only; the underlying
9853    // PathBuf remains unchanged so file operations are unaffected.
9854    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
9855    // \\?\C:\path           →  C:\path          (local drive)
9856    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
9857        return format!(r"\\{rest}");
9858    }
9859    if let Some(rest) = s.strip_prefix(r"\\?\") {
9860        return rest.to_owned();
9861    }
9862    s.into_owned()
9863}
9864
9865fn sanitize_path_str(s: &str) -> String {
9866    // Forward-slash variants of the Windows extended-length prefix that appear
9867    // when paths stored as plain strings have been processed through some path
9868    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
9869    if let Some(rest) = s.strip_prefix("//?/UNC/") {
9870        return format!("//{rest}");
9871    }
9872    if let Some(rest) = s.strip_prefix("//?/") {
9873        return rest.to_owned();
9874    }
9875    display_path(Path::new(s))
9876}
9877
9878fn workspace_root() -> PathBuf {
9879    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
9880    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
9881        let p = PathBuf::from(root);
9882        if p.is_dir() {
9883            return p;
9884        }
9885    }
9886
9887    // Current working directory — works for `cargo run` from the project root
9888    // and for scripts/run.sh which cds there first.
9889    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
9890}
9891
9892/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
9893fn make_git_label(repo: &str, ref_name: &str) -> String {
9894    if repo.is_empty() || ref_name.is_empty() {
9895        return String::new();
9896    }
9897    let base = repo
9898        .trim_end_matches('/')
9899        .trim_end_matches(".git")
9900        .rsplit('/')
9901        .next()
9902        .unwrap_or("repo");
9903    let ref_safe: String = ref_name
9904        .chars()
9905        .map(|c| {
9906            if c.is_alphanumeric() || c == '-' || c == '.' {
9907                c
9908            } else {
9909                '_'
9910            }
9911        })
9912        .collect();
9913    format!("{base}_at_{ref_safe}_sloc")
9914}
9915
9916/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
9917fn desktop_dir() -> PathBuf {
9918    if let Ok(profile) = std::env::var("USERPROFILE") {
9919        let p = PathBuf::from(profile).join("Desktop");
9920        if p.exists() {
9921            return p;
9922        }
9923    }
9924    if let Ok(home) = std::env::var("HOME") {
9925        let p = PathBuf::from(home).join("Desktop");
9926        if p.exists() {
9927            return p;
9928        }
9929    }
9930    workspace_root().join("out").join("web")
9931}
9932
9933fn resolve_input_path(raw: &str) -> PathBuf {
9934    let trimmed = raw.trim();
9935    if trimmed.is_empty() {
9936        return workspace_root().join("samples").join("basic");
9937    }
9938
9939    let candidate = PathBuf::from(trimmed);
9940    let resolved = if candidate.is_absolute() {
9941        candidate
9942    } else {
9943        let rooted = workspace_root().join(&candidate);
9944        if rooted.exists() {
9945            rooted
9946        } else {
9947            workspace_root().join(candidate)
9948        }
9949    };
9950
9951    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
9952    // strip that prefix so stored paths and the displayed "Project path" are clean.
9953    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
9954    PathBuf::from(display_path(&canonical))
9955}
9956
9957fn dir_size_bytes(path: &Path) -> u64 {
9958    let mut total = 0u64;
9959    if let Ok(rd) = fs::read_dir(path) {
9960        for entry in rd.filter_map(Result::ok) {
9961            let p = entry.path();
9962            if p.is_file() {
9963                if let Ok(meta) = p.metadata() {
9964                    total += meta.len();
9965                }
9966            } else if p.is_dir() {
9967                total += dir_size_bytes(&p);
9968            }
9969        }
9970    }
9971    total
9972}
9973
9974#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
9975fn format_dir_size(bytes: u64) -> String {
9976    if bytes >= 1_073_741_824 {
9977        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
9978    } else if bytes >= 1_048_576 {
9979        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
9980    } else if bytes >= 1_024 {
9981        format!("{:.0} KB", bytes as f64 / 1_024.0)
9982    } else {
9983        format!("{bytes} B")
9984    }
9985}
9986
9987fn render_submodule_chips(
9988    root: &Path,
9989    submodules: &[(String, std::path::PathBuf)],
9990    out: &mut String,
9991) {
9992    use std::fmt::Write as _;
9993    let count = submodules.len();
9994    out.push_str(r#"<div class="submodule-preview-strip">"#);
9995    write!(
9996        out,
9997        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>"#,
9998        if count == 1 { "" } else { "s" }
9999    )
10000    .ok();
10001    out.push_str(r#"<div class="submodule-preview-chips">"#);
10002    for (sub_name, sub_rel_path) in submodules {
10003        let sub_abs = root.join(sub_rel_path);
10004        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
10005        let mut sub_stats = PreviewStats::default();
10006        let mut sub_rows: Vec<PreviewRow> = Vec::new();
10007        let mut sub_langs: Vec<&'static str> = Vec::new();
10008        let mut sub_budget = PreviewBudget {
10009            shown: 0,
10010            max_entries: 2000,
10011            max_depth: 9,
10012        };
10013        let mut sub_next_id = 1usize;
10014        let _ = collect_preview_rows(
10015            &sub_abs,
10016            &sub_abs,
10017            0,
10018            None,
10019            &mut sub_next_id,
10020            &mut sub_budget,
10021            &mut sub_stats,
10022            &mut sub_rows,
10023            &mut sub_langs,
10024            &[],
10025            &[],
10026        );
10027        let stats_json = format!(
10028            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
10029            sub_stats.directories,
10030            sub_stats.files,
10031            sub_stats.supported,
10032            sub_stats.skipped,
10033            sub_stats.unsupported
10034        );
10035        write!(
10036            out,
10037            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>"#,
10038            escape_html(sub_name),
10039            escape_html(&sub_rel_path.to_string_lossy()),
10040            escape_html(&sub_size),
10041            escape_html(&stats_json),
10042            escape_html(sub_name),
10043            escape_html(&sub_size),
10044        )
10045        .ok();
10046    }
10047    out.push_str(
10048        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
10049    );
10050    out.push_str(r"</div>");
10051}
10052
10053fn render_language_pills_row(languages: &[&str], out: &mut String) {
10054    use std::fmt::Write as _;
10055    if languages.is_empty() {
10056        out.push_str(
10057            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
10058        );
10059        return;
10060    }
10061    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
10062    for language in languages {
10063        if let Some(icon) = language_icon_file(language) {
10064            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();
10065        } else if let Some(svg) = language_inline_svg(language) {
10066            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();
10067        } else {
10068            write!(
10069                out,
10070                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
10071                escape_html(&language.to_ascii_lowercase()),
10072                escape_html(language)
10073            )
10074            .ok();
10075        }
10076    }
10077}
10078
10079#[allow(clippy::too_many_lines)]
10080fn build_preview_html(
10081    root: &Path,
10082    include_patterns: &[String],
10083    exclude_patterns: &[String],
10084) -> Result<String> {
10085    if !root.exists() {
10086        return Ok(format!(
10087            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
10088            escape_html(&display_path(root))
10089        ));
10090    }
10091
10092    let _selected = display_path(root);
10093    let mut stats = PreviewStats::default();
10094    let mut rows = Vec::new();
10095    let mut languages = Vec::new();
10096    let mut budget = PreviewBudget {
10097        shown: 0,
10098        max_entries: 600,
10099        max_depth: 9,
10100    };
10101    let mut next_row_id = 1usize;
10102
10103    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
10104        || root.to_string_lossy().into_owned(),
10105        std::string::ToString::to_string,
10106    );
10107    let root_modified = root
10108        .metadata()
10109        .ok()
10110        .and_then(|meta| meta.modified().ok())
10111        .map_or_else(|| "-".to_string(), format_system_time);
10112
10113    rows.push(PreviewRow {
10114        row_id: 0,
10115        parent_row_id: None,
10116        depth: 0,
10117        name: format!("{root_name}/"),
10118        kind: PreviewKind::Dir,
10119        is_dir: true,
10120        language: None,
10121        modified: root_modified,
10122        type_label: "Directory".to_string(),
10123    });
10124    collect_preview_rows(
10125        root,
10126        root,
10127        0,
10128        Some(0),
10129        &mut next_row_id,
10130        &mut budget,
10131        &mut stats,
10132        &mut rows,
10133        &mut languages,
10134        include_patterns,
10135        exclude_patterns,
10136    )?;
10137
10138    let root_size = format_dir_size(dir_size_bytes(root));
10139
10140    let mut out = String::new();
10141    write!(
10142        out,
10143        r#"<div class="explorer-wrap" data-project-size="{}">"#,
10144        escape_html(&root_size)
10145    )
10146    .ok();
10147    out.push_str(r#"<div class="explorer-toolbar compact">"#);
10148    out.push_str(r#"<div class="explorer-title-group">"#);
10149    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
10150    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
10151    out.push_str(r"</div></div>");
10152
10153    out.push_str(r#"<div class="scope-stats">"#);
10154    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();
10155    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();
10156    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();
10157    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();
10158    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();
10159    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>"#);
10160    out.push_str(r"</div>");
10161
10162    let submodules = sloc_core::detect_submodules(root);
10163    if !submodules.is_empty() {
10164        render_submodule_chips(root, &submodules, &mut out);
10165    }
10166
10167    out.push_str(r#"<div class="scope-info-row">"#);
10168    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
10169    render_language_pills_row(&languages, &mut out);
10170    out.push_str(r"</div></div>");
10171    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>"#);
10172    out.push_str(r"</div>");
10173
10174    out.push_str(r#"<div class="file-explorer-shell">"#);
10175    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>"#);
10176    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>"#);
10177    out.push_str(r#"<div class="file-explorer-tree">"#);
10178    for row in rows {
10179        let status_label = row.kind.label();
10180        let lang_attr = row.language.unwrap_or("");
10181        let toggle_html = if row.is_dir {
10182            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
10183                .to_string()
10184        } else {
10185            r#"<span class="tree-bullet">•</span>"#.to_string()
10186        };
10187        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();
10188    }
10189    if budget.shown >= budget.max_entries {
10190        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>"#);
10191    }
10192    out.push_str(r"</div></div></div>");
10193
10194    Ok(out)
10195}
10196
10197#[derive(Default)]
10198struct PreviewStats {
10199    directories: usize,
10200    files: usize,
10201    supported: usize,
10202    skipped: usize,
10203    unsupported: usize,
10204}
10205
10206struct PreviewRow {
10207    row_id: usize,
10208    parent_row_id: Option<usize>,
10209    depth: usize,
10210    name: String,
10211    kind: PreviewKind,
10212    is_dir: bool,
10213    language: Option<&'static str>,
10214    modified: String,
10215    type_label: String,
10216}
10217
10218#[derive(Copy, Clone)]
10219enum PreviewKind {
10220    Dir,
10221    Supported,
10222    Skipped,
10223    Unsupported,
10224}
10225
10226impl PreviewKind {
10227    const fn filter_key(self) -> &'static str {
10228        match self {
10229            Self::Dir => "dir",
10230            Self::Supported => "supported",
10231            Self::Skipped => "skipped",
10232            Self::Unsupported => "unsupported",
10233        }
10234    }
10235
10236    const fn label(self) -> &'static str {
10237        match self {
10238            Self::Dir => "dir",
10239            Self::Supported => "supported",
10240            Self::Skipped => "skipped by policy",
10241            Self::Unsupported => "unsupported",
10242        }
10243    }
10244
10245    const fn badge_class(self) -> &'static str {
10246        match self {
10247            Self::Dir => "badge badge-dir",
10248            Self::Supported => "badge badge-scan",
10249            Self::Skipped => "badge badge-skip",
10250            Self::Unsupported => "badge badge-unsupported",
10251        }
10252    }
10253
10254    const fn node_class(self) -> &'static str {
10255        match self {
10256            Self::Dir => "tree-node-dir",
10257            Self::Supported => "tree-node-supported",
10258            Self::Skipped => "tree-node-skipped",
10259            Self::Unsupported => "tree-node-unsupported",
10260        }
10261    }
10262}
10263
10264struct PreviewBudget {
10265    shown: usize,
10266    max_entries: usize,
10267    max_depth: usize,
10268}
10269
10270/// Handle a single directory entry inside `collect_preview_rows`.
10271/// Returns `true` when the entry was handled (caller should `continue`).
10272#[allow(clippy::too_many_arguments)]
10273fn handle_preview_dir_entry(
10274    root: &Path,
10275    path: &Path,
10276    name: &str,
10277    modified: String,
10278    depth: usize,
10279    parent_row_id: Option<usize>,
10280    row_id: usize,
10281    next_row_id: &mut usize,
10282    budget: &mut PreviewBudget,
10283    stats: &mut PreviewStats,
10284    rows: &mut Vec<PreviewRow>,
10285    languages: &mut Vec<&'static str>,
10286    include_patterns: &[String],
10287    exclude_patterns: &[String],
10288) -> Result<()> {
10289    let relative = preview_relative_path(root, path);
10290    if should_skip_preview_directory(&relative, exclude_patterns) {
10291        return Ok(());
10292    }
10293    stats.directories += 1;
10294    rows.push(PreviewRow {
10295        row_id,
10296        parent_row_id,
10297        depth: depth + 1,
10298        name: format!("{name}/"),
10299        kind: PreviewKind::Dir,
10300        is_dir: true,
10301        language: None,
10302        modified,
10303        type_label: "Directory".to_string(),
10304    });
10305    budget.shown += 1;
10306    if !matches!(name, ".git" | "node_modules" | "target") {
10307        collect_preview_rows(
10308            root,
10309            path,
10310            depth + 1,
10311            Some(row_id),
10312            next_row_id,
10313            budget,
10314            stats,
10315            rows,
10316            languages,
10317            include_patterns,
10318            exclude_patterns,
10319        )?;
10320    }
10321    Ok(())
10322}
10323
10324/// Handle a single file entry inside `collect_preview_rows`.
10325#[allow(clippy::too_many_arguments)]
10326fn handle_preview_file_entry(
10327    root: &Path,
10328    path: &Path,
10329    name: &str,
10330    modified: String,
10331    depth: usize,
10332    parent_row_id: Option<usize>,
10333    row_id: usize,
10334    budget: &mut PreviewBudget,
10335    stats: &mut PreviewStats,
10336    rows: &mut Vec<PreviewRow>,
10337    languages: &mut Vec<&'static str>,
10338    include_patterns: &[String],
10339    exclude_patterns: &[String],
10340) {
10341    let relative = preview_relative_path(root, path);
10342    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
10343        return;
10344    }
10345    stats.files += 1;
10346    let kind = classify_preview_file(name);
10347    match kind {
10348        PreviewKind::Supported => stats.supported += 1,
10349        PreviewKind::Skipped => stats.skipped += 1,
10350        PreviewKind::Unsupported => stats.unsupported += 1,
10351        PreviewKind::Dir => {}
10352    }
10353    let language = detect_language_name(name);
10354    if let Some(lang) = language {
10355        if !languages.contains(&lang) {
10356            languages.push(lang);
10357        }
10358    }
10359    rows.push(PreviewRow {
10360        row_id,
10361        parent_row_id,
10362        depth: depth + 1,
10363        name: name.to_owned(),
10364        kind,
10365        is_dir: false,
10366        language,
10367        modified,
10368        type_label: preview_type_label(name, language, kind),
10369    });
10370    budget.shown += 1;
10371}
10372
10373#[allow(clippy::too_many_arguments)]
10374#[allow(clippy::too_many_lines)]
10375fn collect_preview_rows(
10376    root: &Path,
10377    dir: &Path,
10378    depth: usize,
10379    parent_row_id: Option<usize>,
10380    next_row_id: &mut usize,
10381    budget: &mut PreviewBudget,
10382    stats: &mut PreviewStats,
10383    rows: &mut Vec<PreviewRow>,
10384    languages: &mut Vec<&'static str>,
10385    include_patterns: &[String],
10386    exclude_patterns: &[String],
10387) -> Result<()> {
10388    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
10389        return Ok(());
10390    }
10391
10392    let mut entries = fs::read_dir(dir)
10393        .with_context(|| format!("failed to read directory {}", dir.display()))?
10394        .filter_map(std::result::Result::ok)
10395        .collect::<Vec<_>>();
10396    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
10397
10398    for entry in entries {
10399        if budget.shown >= budget.max_entries {
10400            break;
10401        }
10402
10403        let path = entry.path();
10404        let name = entry.file_name().to_string_lossy().into_owned();
10405        let Ok(metadata) = entry.metadata() else {
10406            continue;
10407        };
10408        let row_id = *next_row_id;
10409        *next_row_id += 1;
10410        let modified = metadata
10411            .modified()
10412            .ok()
10413            .map_or_else(|| "-".to_string(), format_system_time);
10414
10415        if metadata.is_dir() {
10416            handle_preview_dir_entry(
10417                root,
10418                &path,
10419                &name,
10420                modified,
10421                depth,
10422                parent_row_id,
10423                row_id,
10424                next_row_id,
10425                budget,
10426                stats,
10427                rows,
10428                languages,
10429                include_patterns,
10430                exclude_patterns,
10431            )?;
10432            continue;
10433        }
10434
10435        if metadata.is_file() {
10436            handle_preview_file_entry(
10437                root,
10438                &path,
10439                &name,
10440                modified,
10441                depth,
10442                parent_row_id,
10443                row_id,
10444                budget,
10445                stats,
10446                rows,
10447                languages,
10448                include_patterns,
10449                exclude_patterns,
10450            );
10451        }
10452    }
10453
10454    Ok(())
10455}
10456
10457fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
10458    if let Some(language) = language {
10459        return format!("{language} source");
10460    }
10461    let lower = name.to_ascii_lowercase();
10462    let ext = Path::new(&lower)
10463        .extension()
10464        .and_then(|e| e.to_str())
10465        .unwrap_or("");
10466    match kind {
10467        PreviewKind::Skipped => {
10468            if lower.ends_with(".min.js") {
10469                "Minified asset".to_string()
10470            } else if [
10471                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
10472            ]
10473            .contains(&ext)
10474            {
10475                "Binary or archive".to_string()
10476            } else {
10477                "Skipped file".to_string()
10478            }
10479        }
10480        PreviewKind::Unsupported => {
10481            if ext.is_empty() {
10482                "Unsupported file".to_string()
10483            } else {
10484                format!("{} file", ext.to_ascii_uppercase())
10485            }
10486        }
10487        PreviewKind::Supported => "Supported source".to_string(),
10488        PreviewKind::Dir => "Directory".to_string(),
10489    }
10490}
10491
10492fn format_system_time(time: SystemTime) -> String {
10493    #[allow(clippy::cast_possible_wrap)]
10494    let secs = match time.duration_since(UNIX_EPOCH) {
10495        Ok(duration) => duration.as_secs() as i64,
10496        Err(_) => return "-".to_string(),
10497    };
10498    let days = secs.div_euclid(86_400);
10499    let secs_of_day = secs.rem_euclid(86_400);
10500    let (year, month, day) = civil_from_days(days);
10501    let hour = secs_of_day / 3_600;
10502    let minute = (secs_of_day % 3_600) / 60;
10503    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
10504}
10505
10506#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
10507fn civil_from_days(days: i64) -> (i32, u32, u32) {
10508    let z = days + 719_468;
10509    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
10510    let doe = z - era * 146_097;
10511    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
10512    let y = yoe + era * 400;
10513    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
10514    let mp = (5 * doy + 2) / 153;
10515    let d = doy - (153 * mp + 2) / 5 + 1;
10516    let m = mp + if mp < 10 { 3 } else { -9 };
10517    let year = y + i64::from(m <= 2);
10518    (year as i32, m as u32, d as u32)
10519}
10520
10521// The input is already lowercased via `to_ascii_lowercase()` before calling
10522// `ends_with`, so the comparisons are inherently case-insensitive.
10523#[allow(clippy::case_sensitive_file_extension_comparisons)]
10524fn detect_language_name(name: &str) -> Option<&'static str> {
10525    let lower = name.to_ascii_lowercase();
10526    if lower.ends_with(".c") || lower.ends_with(".h") {
10527        Some("C")
10528    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
10529        .iter()
10530        .any(|s| lower.ends_with(s))
10531    {
10532        Some("C++")
10533    } else if lower.ends_with(".cs") {
10534        Some("C#")
10535    } else if lower.ends_with(".py") {
10536        Some("Python")
10537    } else if lower.ends_with(".sh") {
10538        Some("Shell")
10539    } else if [".ps1", ".psm1", ".psd1"]
10540        .iter()
10541        .any(|s| lower.ends_with(s))
10542    {
10543        Some("PowerShell")
10544    } else {
10545        None
10546    }
10547}
10548
10549fn language_icon_file(language: &str) -> Option<&'static str> {
10550    match language {
10551        "C" => Some("c.png"),
10552        "C++" => Some("cpp.png"),
10553        "C#" => Some("c-sharp.png"),
10554        "Python" => Some("python.png"),
10555        "Shell" => Some("shell.png"),
10556        "PowerShell" => Some("powershell.png"),
10557        "JavaScript" => Some("java-script.png"),
10558        "HTML" => Some("html-5.png"),
10559        "Java" => Some("java.png"),
10560        "Visual Basic" => Some("visual-basic.png"),
10561        "Assembly" => Some("asm.png"),
10562        "Go" => Some("go.png"),
10563        "R" => Some("r.png"),
10564        "XML" => Some("xml.png"),
10565        "Groovy" => Some("groovy.png"),
10566        "Dockerfile" => Some("docker.png"),
10567        "Makefile" => Some("makefile.svg"),
10568        "Perl" => Some("perl.svg"),
10569        _ => None,
10570    }
10571}
10572
10573// Inline SVG badges for languages that have no PNG icon in images/icons/.
10574// Using inline SVG keeps the web UI fully self-contained — no extra files
10575// needed on disk, no 404s on air-gapped deployments.
10576// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
10577fn language_inline_svg(language: &str) -> Option<&'static str> {
10578    match language {
10579        "Rust" => Some(
10580            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>"##,
10581        ),
10582        "TypeScript" => Some(
10583            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>"##,
10584        ),
10585        _ => None,
10586    }
10587}
10588
10589// The input is already lowercased via `to_ascii_lowercase()` before the
10590// `ends_with` calls, so these comparisons are inherently case-insensitive.
10591#[allow(clippy::case_sensitive_file_extension_comparisons)]
10592fn classify_preview_file(name: &str) -> PreviewKind {
10593    let lower = name.to_ascii_lowercase();
10594
10595    let scannable = [
10596        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
10597        ".psm1", ".psd1",
10598    ]
10599    .iter()
10600    .any(|suffix| lower.ends_with(suffix));
10601
10602    if scannable {
10603        PreviewKind::Supported
10604    } else if lower.ends_with(".min.js")
10605        || lower.ends_with(".lock")
10606        || lower.ends_with(".png")
10607        || lower.ends_with(".jpg")
10608        || lower.ends_with(".jpeg")
10609        || lower.ends_with(".gif")
10610        || lower.ends_with(".zip")
10611        || lower.ends_with(".pdf")
10612        || lower.ends_with(".pyc")
10613        || lower.ends_with(".xz")
10614        || lower.ends_with(".tar")
10615        || lower.ends_with(".gz")
10616    {
10617        PreviewKind::Skipped
10618    } else {
10619        PreviewKind::Unsupported
10620    }
10621}
10622
10623fn preview_relative_path(root: &Path, path: &Path) -> String {
10624    path.strip_prefix(root)
10625        .ok()
10626        .unwrap_or(path)
10627        .to_string_lossy()
10628        .replace('\\', "/")
10629        .trim_matches('/')
10630        .to_string()
10631}
10632
10633fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
10634    if relative.is_empty() {
10635        return false;
10636    }
10637
10638    exclude_patterns.iter().any(|pattern| {
10639        wildcard_match(pattern, relative)
10640            || wildcard_match(pattern, &format!("{relative}/"))
10641            || wildcard_match(pattern, &format!("{relative}/placeholder"))
10642    })
10643}
10644
10645fn should_include_preview_file(
10646    relative: &str,
10647    include_patterns: &[String],
10648    exclude_patterns: &[String],
10649) -> bool {
10650    if relative.is_empty() {
10651        return true;
10652    }
10653
10654    let included = include_patterns.is_empty()
10655        || include_patterns
10656            .iter()
10657            .any(|pattern| wildcard_match(pattern, relative));
10658    let excluded = exclude_patterns
10659        .iter()
10660        .any(|pattern| wildcard_match(pattern, relative));
10661
10662    included && !excluded
10663}
10664
10665fn wildcard_match(pattern: &str, candidate: &str) -> bool {
10666    let pattern = pattern.trim().replace('\\', "/");
10667    let candidate = candidate.trim().replace('\\', "/");
10668    let p = pattern.as_bytes();
10669    let c = candidate.as_bytes();
10670    let mut pi = 0usize;
10671    let mut ci = 0usize;
10672    let mut star: Option<usize> = None;
10673    let mut star_match = 0usize;
10674
10675    while ci < c.len() {
10676        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
10677            pi += 1;
10678            ci += 1;
10679        } else if pi < p.len() && p[pi] == b'*' {
10680            while pi < p.len() && p[pi] == b'*' {
10681                pi += 1;
10682            }
10683            star = Some(pi);
10684            star_match = ci;
10685        } else if let Some(star_pi) = star {
10686            star_match += 1;
10687            ci = star_match;
10688            pi = star_pi;
10689        } else {
10690            return false;
10691        }
10692    }
10693
10694    while pi < p.len() && p[pi] == b'*' {
10695        pi += 1;
10696    }
10697
10698    pi == p.len()
10699}
10700
10701fn escape_html(value: &str) -> String {
10702    value
10703        .replace('&', "&amp;")
10704        .replace('<', "&lt;")
10705        .replace('>', "&gt;")
10706        .replace('"', "&quot;")
10707        .replace('\'', "&#39;")
10708}
10709
10710#[derive(Clone)]
10711struct SubmoduleRow {
10712    name: String,
10713    relative_path: String,
10714    files_analyzed: u64,
10715    code_lines: u64,
10716    comment_lines: u64,
10717    blank_lines: u64,
10718    total_physical_lines: u64,
10719    html_url: Option<String>,
10720}
10721
10722#[derive(Template)]
10723#[template(
10724    source = r##"
10725<!doctype html>
10726<html lang="en">
10727<head>
10728  <meta charset="utf-8">
10729  <title>OxideSLOC | tmp-sloc</title>
10730  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10731  <style nonce="{{ csp_nonce }}">
10732    :root {
10733      --bg: #efe9e2;
10734      --surface: #fcfaf7;
10735      --surface-2: #f7f0e8;
10736      --surface-3: #efe3d5;
10737      --line: #dfcfbf;
10738      --line-strong: #cfb29c;
10739      --text: #2f241c;
10740      --muted: #6f6257;
10741      --muted-2: #917f71;
10742      --nav: #b85d33;
10743      --nav-2: #7a371b;
10744      --accent: #2563eb;
10745      --accent-2: #1d4ed8;
10746      --oxide: #b85d33;
10747      --oxide-2: #8f4220;
10748      --success-bg: #eaf9ee;
10749      --success-text: #1c8746;
10750      --warn-bg: #fff2d8;
10751      --warn-text: #926000;
10752      --danger-bg: #fdeaea;
10753      --danger-text: #b33b3b;
10754      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
10755      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
10756      --radius: 14px;
10757    }
10758
10759    body.dark-theme {
10760      --bg: #1b1511;
10761      --surface: #261c17;
10762      --surface-2: #2d221d;
10763      --surface-3: #372922;
10764      --line: #524238;
10765      --line-strong: #6c5649;
10766      --text: #f5ece6;
10767      --muted: #c7b7aa;
10768      --muted-2: #aa9485;
10769      --nav: #b85d33;
10770      --nav-2: #7a371b;
10771      --accent: #6f9bff;
10772      --accent-2: #4a78ee;
10773      --oxide: #d37a4c;
10774      --oxide-2: #b35428;
10775      --success-bg: #163927;
10776      --success-text: #8fe2a8;
10777      --warn-bg: #3c2d11;
10778      --warn-text: #f3cb75;
10779      --danger-bg: #3d1f1f;
10780      --danger-text: #ff9f9f;
10781      --shadow: 0 14px 28px rgba(0,0,0,0.28);
10782      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
10783    }
10784
10785    * { box-sizing: border-box; }
10786    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); }
10787    html { overflow-y: scroll; }
10788    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
10789    .top-nav, .page, .loading { position: relative; z-index: 2; }
10790    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
10791    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
10792    .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); }
10793    .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; }
10794    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
10795    .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)); }
10796    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
10797    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
10798    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
10799    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
10800    .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; }
10801    .nav-project-pill.visible { display:inline-flex; }
10802    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
10803    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
10804    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
10805    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
10806    @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; } }
10807    .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; }
10808    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
10809    .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; }
10810    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
10811    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
10812    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
10813    .theme-toggle .icon-sun { display:none; }
10814    body.dark-theme .theme-toggle .icon-sun { display:block; }
10815    body.dark-theme .theme-toggle .icon-moon { display:none; }
10816    .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;}
10817    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
10818    .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);}
10819    .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;}
10820    .settings-close:hover{color:var(--text);background:var(--surface-2);}
10821    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
10822    .settings-modal-body{padding:14px 16px 16px;}
10823    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
10824    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
10825    .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;}
10826    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
10827    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
10828    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
10829    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
10830    .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;}
10831    .tz-select:focus{border-color:var(--oxide);}
10832    .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; }
10833    .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;}
10834    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
10835    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
10836    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
10837    .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; }
10838    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
10839    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
10840    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
10841    .wb-stats-header { padding: 10px 24px 0; }
10842    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
10843    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
10844    .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; }
10845    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
10846    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
10847    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
10848    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
10849    .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; }
10850    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
10851    .ws-stat-analyzers { position: relative; }
10852    .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; }
10853    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
10854    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
10855    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
10856    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
10857    .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; }
10858    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
10859    .ws-divider { display: none; }
10860    .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%; }
10861    .ws-path-link:hover { color:var(--oxide); }
10862    body.dark-theme .ws-path-link { color:var(--oxide); }
10863    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
10864    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
10865    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
10866    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
10867    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
10868    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
10869    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
10870    .ws-mini-box-lg { flex:2 1 0; }
10871    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
10872    .ws-mini-box-br { flex:1.5 1 0; }
10873    .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); }
10874    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
10875    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
10876    #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; }
10877    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
10878    .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; }
10879    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
10880    .git-source-banner strong { font-weight:800; color:var(--text); }
10881    .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; }
10882    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
10883    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
10884    .git-source-banner a:hover { text-decoration:underline; }
10885    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
10886    .path-scope-sep { background:var(--line); margin:4px 14px; }
10887    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
10888    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
10889    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
10890    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
10891    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
10892    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
10893    .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; }
10894    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
10895    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
10896    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
10897    .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; }
10898    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
10899    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
10900    [data-wb-tip] { cursor:help; }
10901    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
10902    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
10903    .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; }
10904    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
10905    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
10906    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
10907    .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; }
10908    .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); }
10909    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
10910    .side-info-card { padding: 18px; }
10911    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
10912    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
10913    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
10914    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
10915    .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); }
10916    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
10917    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
10918    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
10919    .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; }
10920    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
10921    .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; }
10922    .side-stack::-webkit-scrollbar { display: none; }
10923    .step-nav { padding: 20px 16px; }
10924    .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); }
10925    .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; }
10926    .step-button:hover { background: var(--surface-2); }
10927    .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); }
10928    .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; }
10929    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
10930    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
10931    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
10932    .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); }
10933    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
10934    .step-nav-sum-row:last-child { border-bottom:none; }
10935    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
10936    .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; }
10937    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
10938    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
10939    .quick-scan-section { padding: 10px 4px 14px; }
10940    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
10941    .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; }
10942    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
10943    .quick-scan-btn:active { transform:translateY(0); }
10944    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
10945    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
10946    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
10947    @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);} }
10948    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
10949    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
10950    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
10951    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
10952    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
10953    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
10954    .step-button.done .step-check { opacity:1; }
10955    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
10956    .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; }
10957    .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; }
10958    .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; }
10959    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
10960    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
10961    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
10962    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
10963    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
10964    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
10965    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
10966    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
10967    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
10968    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
10969    .card-body { padding: 22px; }
10970    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
10971    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
10972    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
10973    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
10974    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
10975    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
10976    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
10977    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
10978    .field { min-width:0; }
10979    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
10980    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; }
10981    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); }
10982    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
10983    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); }
10984    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
10985    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
10986    .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; }
10987    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
10988    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
10989    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
10990    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
10991    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
10992    .input-group.compact { grid-template-columns: 1fr auto auto; }
10993    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
10994    .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)); }
10995    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
10996    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
10997    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
10998    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
10999    .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; }
11000    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
11001    .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; }
11002    .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); }
11003    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
11004    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
11005    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
11006    button.secondary { background: var(--surface); }
11007    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11008    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
11009    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
11010    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
11011    .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); }
11012    .section + .wizard-actions { border-top: none; padding-top: 0; }
11013    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
11014    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11015    .field-help-grid.coupled-help { margin-top: 12px; }
11016    .field-help-grid.preset-grid { align-items: start; }
11017    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
11018    .preset-inline-row .field { margin: 0; }
11019    .preset-inline-row .explainer-card { margin: 0; }
11020    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
11021    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
11022    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
11023    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
11024    .preset-kv-row > :last-child { flex:1; min-width:0; }
11025    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
11026    .output-field-row .field { margin: 0; }
11027    .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; }
11028    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
11029    .step3-subtitle { margin-bottom: 10px; max-width: none; }
11030    .counting-intro { margin-bottom: 8px; max-width: none; }
11031    .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; }
11032    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
11033    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
11034    .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; }
11035    .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; }
11036    .section-spacer-top { margin-top: 28px; }
11037    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
11038    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
11039    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
11040    .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); }
11041    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
11042    .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; }
11043    .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; }
11044    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
11045    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11046    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
11047    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
11048    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
11049    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
11050    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
11051    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
11052    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
11053    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
11054    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
11055    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
11056    .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); }
11057    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
11058    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
11059    .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; }
11060    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
11061    .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; }
11062    .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; }
11063    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
11064    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
11065    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
11066    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
11067    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
11068    .advanced-rule-description strong { color: var(--text); }
11069    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
11070    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
11071    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
11072    .review-link:hover { text-decoration: underline; }
11073    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
11074    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
11075    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
11076    .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; }
11077    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
11078    .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
11079    .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
11080    body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
11081    .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
11082    body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
11083    .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; }
11084    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
11085    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
11086    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
11087    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
11088    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
11089    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
11090    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
11091    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
11092    .review-card ul { padding-left: 18px; margin: 0; }
11093    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
11094    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
11095    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
11096    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
11097    .review-card { min-height: 0; }
11098    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
11099    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
11100    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
11101    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
11102    .lang-overflow-chip { position:relative; cursor:default; }
11103    .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; }
11104    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
11105    .git-inline-row { align-items:start; }
11106    .mixed-line-card { display:flex; flex-direction:column; }
11107    .preset-inline-row .toggle-card { justify-content: center; }
11108        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
11109    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
11110    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
11111    .explorer-title { font-size: 18px; font-weight: 850; }
11112    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
11113    .explorer-subtitle.wide { max-width: none; }
11114    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
11115    .better-spacing { align-items:flex-start; justify-content:flex-end; }
11116    .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; }
11117    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
11118    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
11119    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
11120    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
11121    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
11122    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
11123    .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; }
11124    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
11125    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
11126    .scope-stat-button.supported { background: var(--success-bg); }
11127    .scope-stat-button.skipped { background: var(--warn-bg); }
11128    .scope-stat-button.unsupported { background: var(--danger-bg); }
11129    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
11130    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
11131    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
11132    [data-tooltip] { position: relative; }
11133    [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); }
11134    [data-tooltip]:hover::after { display: block; }
11135    .scope-stat-button[data-tooltip] { cursor: pointer; }
11136    .badge[data-tooltip] { cursor: help; }
11137    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
11138    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
11139    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
11140    .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; }
11141    .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; }
11142    code { display:inline-block; margin-top:0; padding:2px 7px; }
11143    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
11144    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
11145    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
11146    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
11147    .language-pill.muted-pill { color: var(--muted); }
11148    button.language-pill { appearance:none; cursor:pointer; }
11149    .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); }
11150    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
11151    .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; }
11152    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
11153    .file-explorer-search-row { margin-left: auto; }
11154    .explorer-filter-select { min-width: 170px; width: 170px; }
11155    .explorer-search { min-width: 300px; width: 300px; }
11156    .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); }
11157    .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; }
11158    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
11159    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
11160    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
11161    .file-explorer-tree { max-height: 640px; overflow:auto; }
11162    .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); }
11163    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
11164    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
11165    .tree-row.hidden-by-filter { display:none !important; }
11166    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
11167    .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; }
11168    .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; }
11169    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
11170    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
11171    .tree-node { display:inline-flex; align-items:center; min-width:0; }
11172    .tree-node-dir { color: var(--text); font-weight: 800; }
11173    .tree-node-supported { color: var(--success-text); }
11174    .tree-node-skipped { color: var(--warn-text); }
11175    .tree-node-unsupported { color: var(--danger-text); }
11176    .tree-node-more { color: var(--muted-2); font-style: italic; }
11177    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
11178    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
11179    .tree-status-cell { display:flex; justify-content:flex-start; }
11180    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
11181    .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; }
11182    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
11183    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
11184    .cov-scan-idle { display:none; }
11185    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
11186    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
11187    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
11188    .cov-scan-title { font-weight:600; font-size:12.5px; }
11189    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
11190    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
11191    .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; }
11192    .cov-scan-use:hover { opacity:.75; }
11193    .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; }
11194    .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; }
11195    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
11196    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
11197    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
11198    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
11199    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
11200    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
11201    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
11202    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
11203    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
11204    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
11205    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
11206    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
11207    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
11208    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
11209    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
11210    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
11211    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
11212    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
11213    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
11214    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
11215    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
11216    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
11217    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
11218    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
11219    .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); }
11220    .loading.active { display:flex; }
11221    .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; }
11222    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
11223    .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; }
11224    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
11225    .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; }
11226    .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; }
11227    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
11228    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
11229    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
11230    .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; }
11231    .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
11232    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
11233    .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
11234    .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
11235    .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; }
11236    .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; }
11237    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
11238    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
11239    .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; }
11240    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
11241    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
11242    .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; }
11243    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
11244    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
11245    .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; }
11246    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
11247    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
11248    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
11249    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
11250    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
11251    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
11252    .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; }
11253    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
11254    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
11255    .hidden { display:none !important; }
11256    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11257    .site-footer a{color:var(--muted);}
11258    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
11259    @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; } }
11260    .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;}
11261    @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));}}
11262    .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;}
11263    .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; }
11264    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
11265    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
11266    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
11267    .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; }
11268    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
11269    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
11270    .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; }
11271    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
11272    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
11273    .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; }
11274    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
11275    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
11276    .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; }
11277    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
11278    .info-icon-btn:hover { color:var(--text); }
11279    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); }
11280    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
11281    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
11282    .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;}
11283    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
11284    .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;}
11285    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
11286  </style>
11287</head>
11288<body>
11289  <div class="background-watermarks" aria-hidden="true">
11290    <img src="/images/logo/logo-text.png" alt="" />
11291    <img src="/images/logo/logo-text.png" alt="" />
11292    <img src="/images/logo/logo-text.png" alt="" />
11293    <img src="/images/logo/logo-text.png" alt="" />
11294    <img src="/images/logo/logo-text.png" alt="" />
11295    <img src="/images/logo/logo-text.png" alt="" />
11296    <img src="/images/logo/logo-text.png" alt="" />
11297    <img src="/images/logo/logo-text.png" alt="" />
11298    <img src="/images/logo/logo-text.png" alt="" />
11299    <img src="/images/logo/logo-text.png" alt="" />
11300    <img src="/images/logo/logo-text.png" alt="" />
11301    <img src="/images/logo/logo-text.png" alt="" />
11302    <img src="/images/logo/logo-text.png" alt="" />
11303    <img src="/images/logo/logo-text.png" alt="" />
11304  </div>
11305  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11306  <div class="top-nav">
11307    <div class="top-nav-inner">
11308      <a class="brand" href="/">
11309        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
11310        <div class="brand-copy">
11311          <div class="brand-title">OxideSLOC</div>
11312          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
11313        </div>
11314      </a>
11315      <div class="nav-project-slot">
11316        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
11317          <span class="nav-project-label">Project</span>
11318          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
11319        </div>
11320      </div>
11321      <div class="nav-status">
11322        <a class="nav-pill" href="/">Home</a>
11323        <div class="nav-dropdown">
11324          <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>
11325          <div class="nav-dropdown-menu">
11326            <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>
11327          </div>
11328        </div>
11329        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11330        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11331        <div class="nav-dropdown">
11332          <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>
11333          <div class="nav-dropdown-menu">
11334            <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>
11335          </div>
11336        </div>
11337        <div class="server-status-wrap" id="server-status-wrap">
11338          <div class="nav-pill server-online-pill" id="server-status-pill">
11339            <span class="status-dot" id="status-dot"></span>
11340            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
11341            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11342          </div>
11343          <div class="server-status-tip">
11344            {% if server_mode %}
11345            OxideSLOC is running in server mode — accessible on your LAN.
11346            {% else %}
11347            OxideSLOC is running locally — only accessible from this machine.
11348            {% endif %}
11349            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11350          </div>
11351        </div>
11352        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11353          <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>
11354        </button>
11355        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
11356          <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>
11357          <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>
11358        </button>
11359      </div>
11360    </div>
11361  </div>
11362
11363  <div class="loading" id="loading">
11364    <div class="loading-card">
11365      <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
11366      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
11367      <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
11368      <div class="lc-path" id="lc-path"></div>
11369      <div class="lc-metrics" id="lc-metrics">
11370        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
11371        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
11372      </div>
11373      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
11374      <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>
11375      <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>
11376      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
11377      <div class="lc-actions hidden" id="lc-actions">
11378        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
11379        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
11380      </div>
11381      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
11382        <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>
11383        Cancel scan
11384      </button>
11385    </div>
11386  </div>
11387
11388  <div class="page">
11389    <div class="workbench-strip">
11390      <div class="workbench-box wb-stats">
11391        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
11392          <span class="wb-stats-title">Analysis session</span>
11393        </div>
11394        <div class="ws-left">
11395          <div class="ws-stat ws-stat-analyzers">
11396            <span class="ws-label">Analyzers</span>
11397            <span class="ws-value">
11398              <span class="ws-badge">41 languages</span>
11399            </span>
11400            <div class="ws-lang-tooltip">
11401              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
11402              <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>
11403              <div class="ws-lang-grid">
11404                <span class="ws-lang-item">Assembly</span>
11405                <span class="ws-lang-item">C</span>
11406                <span class="ws-lang-item">C++</span>
11407                <span class="ws-lang-item">C#</span>
11408                <span class="ws-lang-item">Clojure</span>
11409                <span class="ws-lang-item">CSS</span>
11410                <span class="ws-lang-item">Dart</span>
11411                <span class="ws-lang-item">Dockerfile</span>
11412                <span class="ws-lang-item">Elixir</span>
11413                <span class="ws-lang-item">Erlang</span>
11414                <span class="ws-lang-item">F#</span>
11415                <span class="ws-lang-item">Go</span>
11416                <span class="ws-lang-item">Groovy</span>
11417                <span class="ws-lang-item">Haskell</span>
11418                <span class="ws-lang-item">HTML</span>
11419                <span class="ws-lang-item">Java</span>
11420                <span class="ws-lang-item">JavaScript</span>
11421                <span class="ws-lang-item">Julia</span>
11422                <span class="ws-lang-item">Kotlin</span>
11423                <span class="ws-lang-item">Lua</span>
11424                <span class="ws-lang-item">Makefile</span>
11425                <span class="ws-lang-item">Nim</span>
11426                <span class="ws-lang-item">Obj-C</span>
11427                <span class="ws-lang-item">OCaml</span>
11428                <span class="ws-lang-item">Perl</span>
11429                <span class="ws-lang-item">PHP</span>
11430                <span class="ws-lang-item">PowerShell</span>
11431                <span class="ws-lang-item">Python</span>
11432                <span class="ws-lang-item">R</span>
11433                <span class="ws-lang-item">Ruby</span>
11434                <span class="ws-lang-item">Rust</span>
11435                <span class="ws-lang-item">Scala</span>
11436                <span class="ws-lang-item">SCSS</span>
11437                <span class="ws-lang-item">Shell</span>
11438                <span class="ws-lang-item">SQL</span>
11439                <span class="ws-lang-item">Svelte</span>
11440                <span class="ws-lang-item">Swift</span>
11441                <span class="ws-lang-item">TypeScript</span>
11442                <span class="ws-lang-item">Vue</span>
11443                <span class="ws-lang-item">XML</span>
11444                <span class="ws-lang-item">Zig</span>
11445              </div>
11446            </div>
11447          </div>
11448          <div class="ws-divider"></div>
11449          <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>
11450          <div class="ws-divider"></div>
11451          <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.">
11452            <span class="ws-label">Output</span>
11453            <span class="ws-value">
11454              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
11455                <span id="ws-output-root">project/sloc</span>
11456              </button>
11457            </span>
11458          </div>
11459        </div>
11460      </div>
11461      <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.">
11462        <div class="ws-history-label">Scan history</div>
11463        <div class="ws-history-inner">
11464          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
11465            <div class="ws-mini-label">Scans</div>
11466            <div class="ws-mini-value" id="ws-scan-count">—</div>
11467          </div>
11468          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
11469            <div class="ws-mini-label">Last Scan</div>
11470            <div class="ws-mini-value" id="ws-last-scan">—</div>
11471          </div>
11472          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
11473            <div class="ws-mini-label">Branch</div>
11474            <div class="ws-mini-value" id="ws-branch">—</div>
11475          </div>
11476        </div>
11477      </div>
11478    </div>
11479
11480    <div class="layout">
11481      <aside class="side-stack">
11482        <section class="step-nav">
11483        <h3>Guided scan setup</h3>
11484        <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>
11485        <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>
11486        <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>
11487        <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>
11488
11489        <div class="step-steps-divider"></div>
11490
11491        <div class="step-nav-info" id="step-nav-info">
11492          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
11493          <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>
11494        </div>
11495
11496        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
11497          <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>
11498          <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>
11499          <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>
11500        </div>
11501
11502        <div class="quick-scan-divider"></div>
11503        <div class="quick-scan-section">
11504          <div class="quick-scan-label">No customization needed?</div>
11505          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
11506            <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>
11507            Quick Scan
11508          </button>
11509          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
11510        </div>
11511
11512        <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>
11513        </section>
11514
11515      </aside>
11516
11517      <section class="card">
11518        <div class="card-header">
11519          <div class="card-title-row">
11520            <div>
11521              <h1 class="card-title">Guided scan configuration</h1>
11522              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
11523            </div>
11524            <div class="wizard-progress" aria-label="Scan setup progress">
11525              <div class="wizard-progress-top">
11526                <span class="wizard-progress-label">Setup progress</span>
11527                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
11528              </div>
11529              <div class="wizard-progress-track">
11530                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
11531              </div>
11532            </div>
11533          </div>
11534        </div>
11535        <div class="card-body">
11536          <form method="post" action="/analyze" id="analyze-form">
11537            <div class="wizard-step active" data-step="1">
11538              <div class="section">
11539                <div class="section-kicker">Step 1</div>
11540                <h2>Select project and preview scope</h2>
11541                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
11542                <div class="field">
11543                  <label for="path">Project path</label>
11544                  {% if !git_repo.is_empty() %}
11545                  <div class="git-source-banner">
11546                    <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>
11547                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
11548                    <a href="/git-browser">← Back to Git Browser</a>
11549                  </div>
11550                  {% endif %}
11551                  <div class="path-scope-grid">
11552                      {% if !git_repo.is_empty() %}
11553                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
11554                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
11555                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
11556                      {% else %}
11557                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
11558                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
11559                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
11560                      {% endif %}
11561                    <div class="path-scope-sep"></div>
11562                    <div class="scope-legend-row">
11563                      <span class="scope-legend-label">Scope legend:</span>
11564                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
11565                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
11566                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
11567                    </div>
11568                  </div>
11569                  {% if git_repo.is_empty() %}
11570                  {% if server_mode %}
11571                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
11572                    ℹ️ Files are compressed and streamed — no fixed size limit.
11573                  </div>
11574                  {% endif %}
11575                  <div class="path-info-row">
11576                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
11577                      <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>
11578                      <span id="project-size-text">Project size: —</span>
11579                    </button>
11580                  </div>
11581                  {% else %}
11582                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
11583                  {% endif %}
11584                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
11585                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
11586                </div>
11587
11588                <div class="scope-preview-divider" aria-hidden="true"></div>
11589
11590                <div id="preview-panel">
11591                  <div class="preview-error">Loading preview...</div>
11592                </div>
11593              </div>
11594
11595              <div class="section" style="margin-top:14px;">
11596                <div class="preset-inline-row git-inline-row">
11597                  <div class="toggle-card" style="margin:0;">
11598                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
11599                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
11600                    <label class="checkbox">
11601                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
11602                      <div>
11603                        <span>Detect and separate git submodules</span>
11604                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
11605                      </div>
11606                    </label>
11607                  </div>
11608                  <div class="explainer-card prominent" style="margin:0;">
11609                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11610                    <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>
11611                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
11612    path = libs/core
11613    url  = https://github.com/org/core.git
11614
11615[submodule "libs/ui"]
11616    path = libs/ui
11617    url  = https://github.com/org/ui.git</div>
11618                  </div>
11619                </div>
11620              </div>
11621
11622              <div class="section">
11623                <div class="field-grid">
11624                  <div class="field">
11625                    <label for="include_globs">Include globs</label>
11626                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
11627                    <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>
11628                  </div>
11629                  <div class="field">
11630                    <label for="exclude_globs">Exclude globs</label>
11631                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
11632                    <div id="quick-exclude-chips" class="quick-excl-row">
11633                      <span class="quick-excl-label">Quick add:</span>
11634                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
11635                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
11636                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
11637                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
11638                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
11639                      <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>
11640                    </div>
11641                    <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>
11642                  </div>
11643                </div>
11644                <div class="glob-guidance-grid">
11645                  <div class="glob-guidance-card">
11646                    <strong>How to read them</strong>
11647                    <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>
11648                  </div>
11649                  <div class="glob-guidance-card">
11650                    <strong>Common include examples</strong>
11651                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
11652                  </div>
11653                  <div class="glob-guidance-card">
11654                    <strong>Common exclude examples</strong>
11655                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
11656                  </div>
11657                </div>
11658              </div>
11659
11660              <div class="section" style="margin-top:14px;">
11661                <div class="preset-inline-row git-inline-row">
11662                  <div class="toggle-card" style="margin:0;">
11663                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
11664                    <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>
11665                    <div class="field" style="margin:0;">
11666                      <div class="input-group compact">
11667                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
11668                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
11669                      </div>
11670                      <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>
11671                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
11672                    </div>
11673                  </div>
11674                  <div class="explainer-card prominent" style="margin:0;">
11675                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
11676                    <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>
11677                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
11678lcov --capture --directory . --output-file coverage/lcov.info
11679
11680# C / C++ — llvm-cov (LCOV)
11681llvm-profdata merge -sparse default.profraw -o default.profdata
11682llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
11683
11684# C# — coverlet (Cobertura XML)
11685dotnet test --collect:"XPlat Code Coverage"
11686
11687# Python — pytest-cov (Cobertura XML)
11688pytest --cov --cov-report=xml
11689
11690# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
11691./gradlew jacocoTestReport</div>
11692                  </div>
11693                </div>
11694              </div>
11695
11696              <div class="wizard-actions">
11697                <div class="left"></div>
11698                <div class="right">
11699                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
11700                </div>
11701              </div>
11702            </div>
11703
11704            <div class="wizard-step" data-step="2">
11705              <div class="section">
11706                <div class="section-kicker">Step 2</div>
11707                <h2>Choose counting behavior</h2>
11708                <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>
11709                <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
11710                <div class="subsection-bar">Primary line classification</div>
11711                <div class="preset-kv-row">
11712                  <div class="toggle-card mixed-line-card" style="margin:0;">
11713                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
11714                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
11715                    <select id="mixed_line_policy" name="mixed_line_policy">
11716                      <option value="code_only">Code only</option>
11717                      <option value="code_and_comment">Code and comment</option>
11718                      <option value="comment_only">Comment only</option>
11719                      <option value="separate_mixed_category">Separate mixed category</option>
11720                    </select>
11721                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
11722                  </div>
11723                  <div class="explainer-card prominent" style="margin:0;">
11724                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
11725                    <div class="explainer-body" id="mixed-policy-description"></div>
11726                    <div class="code-sample" id="mixed-policy-example"></div>
11727                  </div>
11728                </div>
11729              </div>
11730
11731              <div class="subsection-bar">Additional scan rules</div>
11732              <div class="scan-rules-grid">
11733                <div class="preset-inline-row">
11734                  <div class="toggle-card" style="margin:0;">
11735                    <div class="field-help-title">Generated files</div>
11736                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
11737                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11738                  </div>
11739                  <div class="explainer-card prominent" style="margin:0;">
11740                    <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>
11741                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
11742# Files matching codegen patterns are excluded:
11743#   *.generated.cs  *.pb.go  *.g.dart</div>
11744                  </div>
11745                </div>
11746                <div class="preset-inline-row">
11747                  <div class="toggle-card" style="margin:0;">
11748                    <div class="field-help-title">Minified files</div>
11749                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
11750                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11751                  </div>
11752                  <div class="explainer-card prominent" style="margin:0;">
11753                    <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>
11754                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
11755# Heuristic: very long lines + low whitespace ratio
11756#   jquery.min.js  bundle.min.css  → skipped</div>
11757                  </div>
11758                </div>
11759                <div class="preset-inline-row">
11760                  <div class="toggle-card" style="margin:0;">
11761                    <div class="field-help-title">Vendor directories</div>
11762                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
11763                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
11764                  </div>
11765                  <div class="explainer-card prominent" style="margin:0;">
11766                    <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>
11767                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
11768# Directories named vendor/ node_modules/ third_party/
11769#   → entire subtree is excluded from totals</div>
11770                  </div>
11771                </div>
11772                <div class="preset-inline-row">
11773                  <div class="toggle-card" style="margin:0;">
11774                    <div class="field-help-title">Lockfiles and manifests</div>
11775                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
11776                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
11777                  </div>
11778                  <div class="explainer-card prominent" style="margin:0;">
11779                    <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>
11780                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
11781# Files like package-lock.json  Cargo.lock  yarn.lock
11782#   → skipped unless this is enabled</div>
11783                  </div>
11784                </div>
11785                <div class="preset-inline-row">
11786                  <div class="toggle-card" style="margin:0;">
11787                    <div class="field-help-title">Binary handling</div>
11788                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
11789                    <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>
11790                  </div>
11791                  <div class="explainer-card prominent" style="margin:0;">
11792                    <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>
11793                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
11794# Detected via long lines + low whitespace heuristic
11795#   .png  .exe  .so  → skipped silently</div>
11796                  </div>
11797                </div>
11798                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
11799                  <div class="toggle-card" style="margin:0;">
11800                    <div class="field-help-title">Python docstrings</div>
11801                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
11802                    <label class="checkbox">
11803                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
11804                      <span>Count as comment-style lines</span>
11805                    </label>
11806                  </div>
11807                  <div class="explainer-card prominent" style="margin:0;">
11808                    <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>
11809                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
11810                  </div>
11811                </div>
11812              </div>
11813              <div class="subsection-bar">IEEE 1045-1992 counting</div>
11814              <div class="scan-rules-grid">
11815                <div class="preset-inline-row">
11816                  <div class="toggle-card" style="margin:0;">
11817                    <div class="field-help-title">Continuation lines</div>
11818                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
11819                    <select name="continuation_line_policy" id="continuation_line_policy">
11820                      <option value="each_physical_line" selected>Each physical line (default)</option>
11821                      <option value="collapse_to_logical">Collapse to logical line</option>
11822                    </select>
11823                  </div>
11824                  <div class="explainer-card prominent" style="margin:0;">
11825                    <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>
11826                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
11827    ((a) &gt; (b) ? (a) : (b))
11828# each_physical_line → 2 SLOC
11829# collapse_to_logical → 1 SLOC</div>
11830                  </div>
11831                </div>
11832                <div class="preset-inline-row">
11833                  <div class="toggle-card" style="margin:0;">
11834                    <div class="field-help-title">Block-comment blanks</div>
11835                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
11836                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
11837                      <option value="count_as_comment" selected>Count as comment (default)</option>
11838                      <option value="count_as_blank">Count as blank</option>
11839                    </select>
11840                  </div>
11841                  <div class="explainer-card prominent" style="margin:0;">
11842                    <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>
11843                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
11844 * Summary line
11845 *              ← blank inside block comment
11846 * Detail line
11847 */
11848# count_as_comment → blank counts toward comments
11849# count_as_blank   → blank counts toward blanks</div>
11850                  </div>
11851                </div>
11852                <div class="preset-inline-row">
11853                  <div class="toggle-card" style="margin:0;">
11854                    <div class="field-help-title">Compiler directives</div>
11855                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
11856                    <select name="count_compiler_directives" id="count_compiler_directives">
11857                      <option value="enabled" selected>Include in code SLOC (default)</option>
11858                      <option value="disabled">Exclude from code SLOC</option>
11859                    </select>
11860                  </div>
11861                  <div class="explainer-card prominent" style="margin:0;">
11862                    <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>
11863                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
11864#define BUF 256     ← compiler directive
11865int main() { … }   ← code
11866# enabled  → 3 code SLOC
11867# disabled → 1 code SLOC + 2 directive lines</div>
11868                  </div>
11869                </div>
11870              </div>
11871
11872              <div class="always-tracked-tip">
11873                <div class="always-tracked-tip-icon">ℹ</div>
11874                <div class="always-tracked-tip-body">
11875                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
11876                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
11877                  <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>
11878                </div>
11879              </div>
11880
11881              <div class="wizard-actions">
11882                <div class="left">
11883                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
11884                </div>
11885                <div class="right">
11886                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
11887                </div>
11888              </div>
11889            </div>
11890
11891            <div class="wizard-step" data-step="3">
11892              <div class="section">
11893                <div class="section-kicker">Step 3</div>
11894                <h2>Output and report identity</h2>
11895                <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>
11896                <div class="preset-kv-row">
11897                  <div class="toggle-card" style="margin:0;">
11898                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
11899                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
11900                    <select id="scan_preset">
11901                      <option value="balanced">Balanced local scan</option>
11902                      <option value="code_focused">Code focused</option>
11903                      <option value="comment_audit">Comment audit</option>
11904                      <option value="deep_review">Deep review</option>
11905                    </select>
11906                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
11907                  </div>
11908                  <div class="explainer-card">
11909                    <div class="field-help-title">Selected scan preset</div>
11910                    <div class="explainer-body" id="scan-preset-description"></div>
11911                    <div class="preset-summary-row" id="scan-preset-summary"></div>
11912                    <div class="code-sample" id="scan-preset-example"></div>
11913                    <div class="preset-note" id="scan-preset-note"></div>
11914                  </div>
11915                </div>
11916                <hr class="step3-separator" />
11917                <div class="preset-kv-row">
11918                  <div class="toggle-card" style="margin:0;">
11919                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
11920                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
11921                    <select id="artifact_preset">
11922                      <option value="review">Review bundle</option>
11923                      <option value="full">Full bundle</option>
11924                      <option value="html_only">HTML only</option>
11925                      <option value="machine">Machine bundle</option>
11926                    </select>
11927                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
11928                  </div>
11929                  <div class="explainer-card">
11930                    <div class="field-help-title">Selected artifact preset</div>
11931                    <div class="explainer-body" id="artifact-preset-description"></div>
11932                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
11933                    <div class="code-sample" id="artifact-preset-example"></div>
11934                  </div>
11935                </div>
11936              </div>
11937
11938              <div class="section section-spacer-top">
11939                <div class="output-field-row">
11940                  <div class="field">
11941                    <label for="output_dir">Output directory</label>
11942                    {% if server_mode %}
11943                    <div class="input-group compact">
11944                      <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);" />
11945                    </div>
11946                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
11947                    {% else %}
11948                    <div class="input-group compact">
11949                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
11950                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
11951                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
11952                    </div>
11953                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
11954                    {% endif %}
11955                  </div>
11956                  <div class="output-field-aside">
11957                    <strong>Where reports land</strong>
11958                    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.
11959                  </div>
11960                </div>
11961              </div>
11962
11963              <div class="section section-spacer-top">
11964                <div class="output-field-row">
11965                  <div class="field">
11966                    <label for="report_title">Report title</label>
11967                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
11968                    <div class="hint">Appears in HTML and PDF output headers.</div>
11969                  </div>
11970                  <div class="output-field-aside">
11971                    <strong>Shown in exported artifacts</strong>
11972                    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.
11973                  </div>
11974                </div>
11975              </div>
11976
11977              <div class="section section-spacer-top">
11978                <div class="output-field-row">
11979                  <div class="field">
11980                    <label for="report_header_footer">Report header / footer</label>
11981                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
11982                    <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>
11983                  </div>
11984                  <div class="output-field-aside">
11985                    <strong>Page-level identification</strong>
11986                    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.
11987                  </div>
11988                </div>
11989              </div>
11990
11991              <div class="section">
11992                <div class="section-kicker">Artifacts</div>
11993                <div class="artifact-grid" style="margin-bottom:24px;">
11994                  <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
11995                    <div class="marker">✓</div>
11996                    <div class="artifact-icon">H</div>
11997                    <h4>HTML report</h4>
11998                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
11999                    <div class="artifact-tags">
12000                      <span class="soft-chip">Best for visual review</span>
12001                      <span class="soft-chip">Embeddable preview</span>
12002                    </div>
12003                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
12004                  </div>
12005                  <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
12006                    <div class="marker">✓</div>
12007                    <div class="artifact-icon">P</div>
12008                    <h4>PDF export</h4>
12009                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
12010                    <div class="artifact-tags">
12011                      <span class="soft-chip">Portable snapshot</span>
12012                      <span class="soft-chip">Good for handoff</span>
12013                    </div>
12014                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
12015                  </div>
12016                  <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
12017                    <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>
12018                    <div class="marker">✓</div>
12019                    <div class="artifact-icon" style="color:var(--muted);">J</div>
12020                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
12021                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
12022                    <div class="artifact-tags">
12023                      <span class="soft-chip">Required for compare</span>
12024                      <span class="soft-chip">Auto-enabled</span>
12025                    </div>
12026                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
12027                  </div>
12028                </div>
12029                <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>
12030              </div>
12031
12032              <div class="wizard-actions">
12033                <div class="left">
12034                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
12035                </div>
12036                <div class="right">
12037                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
12038                </div>
12039              </div>
12040            </div>
12041
12042            <div class="wizard-step" data-step="4">
12043              <div class="section">
12044                <div class="section-kicker">Step 4</div>
12045                <h2>Review selections and run</h2>
12046                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
12047                <div class="review-grid">
12048                  <div class="review-card highlight">
12049                    <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>
12050                    <ul id="review-scan-summary"></ul>
12051                  </div>
12052                  <div class="review-card highlight">
12053                    <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>
12054                    <ul id="review-count-summary"></ul>
12055                  </div>
12056                  <div class="review-card">
12057                    <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>
12058                    <ul id="review-artifact-summary"></ul>
12059                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
12060                  </div>
12061                  <div class="review-card">
12062                    <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>
12063                    <ul id="review-preview-summary"></ul>
12064                  </div>
12065                </div>
12066              </div>
12067
12068              <div class="wizard-actions">
12069                <div class="left">
12070                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
12071                </div>
12072                <div class="right">
12073                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
12074                </div>
12075              </div>
12076            </div>
12077            {% if server_mode %}
12078            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
12079            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
12080            {% endif %}
12081          </form>
12082        </div>
12083      </section>
12084    </div>
12085  </div>
12086
12087  <script nonce="{{ csp_nonce }}">
12088    (function () {
12089      function startScanPhase() {
12090        var phaseEl = document.getElementById("scan-phase");
12091        if (!phaseEl) return;
12092        var phases = [
12093          "Discovering files...",
12094          "Decoding file encodings...",
12095          "Detecting languages...",
12096          "Analyzing source lines...",
12097          "Applying counting policies...",
12098          "Aggregating results...",
12099          "Rendering report..."
12100        ];
12101        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
12102        var i = 0;
12103        function next() {
12104          phaseEl.style.opacity = "0";
12105          setTimeout(function () {
12106            phaseEl.textContent = phases[i];
12107            phaseEl.style.opacity = "0.85";
12108            var delay = durations[i] || 1800;
12109            i++;
12110            if (i < phases.length) { setTimeout(next, delay); }
12111          }, 200);
12112        }
12113        next();
12114      }
12115
12116      var form = document.getElementById("analyze-form");
12117      var loading = document.getElementById("loading");
12118      var submitButton = document.getElementById("submit-button");
12119      var pathInput = document.getElementById("path");
12120      var GIT_MODE = !!(pathInput && pathInput.readOnly);
12121      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
12122      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
12123      var outputDirInput = document.getElementById("output_dir");
12124      var reportTitleInput = document.getElementById("report_title");
12125      var previewPanel = document.getElementById("preview-panel");
12126      var refreshButton = document.getElementById("refresh-preview");
12127      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
12128      var useSamplePath = document.getElementById("use-sample-path");
12129      var useDefaultOutput = document.getElementById("use-default-output");
12130      var browsePath = document.getElementById("browse-path");
12131      var browseOutputDir = document.getElementById("browse-output-dir");
12132      var browseCoverage = document.getElementById("browse-coverage");
12133      var coverageInput = document.getElementById("coverage_file");
12134      var covScanStatus = document.getElementById("cov-scan-status");
12135      var coverageSuggestTimer = null;
12136      var covAutoFilled = false;
12137      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
12138      function fmtBytes(b) {
12139        b = Number(b) || 0;
12140        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
12141        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
12142        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
12143        return b + ' B';
12144      }
12145      var themeToggle = document.getElementById("theme-toggle");
12146
12147      function showBannerToast(msg, isError, opts) {
12148        opts = opts || {};
12149        var t = document.createElement('div');
12150        t.className = isError ? 'toast-error' : 'toast-success';
12151        var topPos = opts.top ? '80px' : null;
12152        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
12153          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
12154          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
12155          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
12156        if (opts.icon) {
12157          var inner = document.createElement('span');
12158          inner.innerHTML = opts.icon + ' ';
12159          t.appendChild(inner);
12160        }
12161        t.appendChild(document.createTextNode(msg));
12162        document.body.appendChild(t);
12163        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
12164      }
12165      var mixedLinePolicy = document.getElementById("mixed_line_policy");
12166      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
12167      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
12168      var scanPreset = document.getElementById("scan_preset");
12169      var artifactPreset = document.getElementById("artifact_preset");
12170      var includeGlobsInput = document.getElementById("include_globs");
12171      var excludeGlobsInput = document.getElementById("exclude_globs");
12172
12173      // Quick-exclude chips — append pattern to exclude_globs textarea.
12174      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
12175        chip.addEventListener("click", function() {
12176          var pattern = chip.getAttribute("data-pattern") || "";
12177          if (!pattern || !excludeGlobsInput) return;
12178          var current = excludeGlobsInput.value.trim();
12179          // For the "skip all" chip, replace any existing dep patterns cleanly.
12180          var patterns = pattern.split("\n");
12181          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
12182          var added = false;
12183          patterns.forEach(function(p) {
12184            p = p.trim();
12185            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
12186          });
12187          if (added) {
12188            excludeGlobsInput.value = lines.join("\n");
12189            excludeGlobsInput.dispatchEvent(new Event("input"));
12190          }
12191          chip.classList.add("active");
12192        });
12193      });
12194
12195      var liveReportTitle = document.getElementById("live-report-title");
12196      var navProjectPill = document.getElementById("nav-project-pill");
12197      var navProjectTitle = document.getElementById("nav-project-title");
12198      var reportTitlePreview = null;
12199      var wizardProgressFill = document.getElementById("wizard-progress-fill");
12200      var wizardProgressValue = document.getElementById("wizard-progress-value");
12201      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
12202      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
12203      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
12204      var reportTitleTouched = false;
12205      var currentStep = 1;
12206      var previewTimer = null;
12207      var quickScanBtn = document.getElementById("quick-scan-btn");
12208
12209      function dismissAnalysisModal() {
12210        if (loading) loading.classList.remove("active");
12211        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12212          var el = document.getElementById(id);
12213          if (el) el.classList.add("hidden");
12214        });
12215        var cancelBtn = document.getElementById("lc-cancel-btn");
12216        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
12217        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
12218        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
12219        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12220        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12221        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12222        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12223        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12224      }
12225
12226      var lcDismissBtn = document.getElementById("lc-dismiss");
12227      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
12228
12229      function startAsyncAnalysis(formData) {
12230        var gitRepo = (formData.get("git_repo") || "").toString();
12231        var gitRef  = (formData.get("git_ref")  || "").toString();
12232        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
12233        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
12234
12235        var pathEl = document.getElementById("lc-path");
12236        if (pathEl) pathEl.textContent = displayPath;
12237
12238        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
12239          var el = document.getElementById(id);
12240          if (el) el.classList.add("hidden");
12241        });
12242        var cancelBtn = document.getElementById("lc-cancel-btn");
12243        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
12244        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
12245        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
12246        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
12247        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
12248        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
12249
12250        if (loading) loading.classList.add("active");
12251
12252        var startTime = Date.now();
12253        var elapsedTimer = setInterval(function() {
12254          var s = Math.floor((Date.now() - startTime) / 1000);
12255          var el = document.getElementById("lc-elapsed");
12256          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
12257        }, 1000);
12258
12259        var warnShown = false, pollRetries = 0, activeWaitId = null;
12260
12261        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
12262
12263        function lcShowCancelled() {
12264          clearInterval(elapsedTimer);
12265          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
12266          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
12267          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
12268          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
12269          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
12270          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
12271          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
12272          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
12273          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12274          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12275        }
12276
12277        var lcCancelBtn = document.getElementById("lc-cancel-btn");
12278        if (lcCancelBtn) {
12279          lcCancelBtn.onclick = function() {
12280            if (!activeWaitId) { dismissAnalysisModal(); return; }
12281            lcCancelBtn.disabled = true;
12282            lcCancelBtn.textContent = "Cancelling…";
12283            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
12284              .then(function() { lcShowCancelled(); })
12285              .catch(function() { lcShowCancelled(); });
12286          };
12287        }
12288
12289        function lcShowError(msg) {
12290          clearInterval(elapsedTimer);
12291          lcSetPhase("Failed");
12292          var msgEl = document.getElementById("lc-err-msg");
12293          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
12294          var errEl = document.getElementById("lc-err");
12295          var actEl = document.getElementById("lc-actions");
12296          if (errEl) errEl.classList.remove("hidden");
12297          if (actEl) actEl.classList.remove("hidden");
12298          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
12299          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
12300        }
12301
12302        function lcPoll(waitId) {
12303          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
12304            .then(function(r) {
12305              if (!r.ok) throw new Error("HTTP " + r.status);
12306              return r.json();
12307            })
12308            .then(function(data) {
12309              pollRetries = 0;
12310              if (data.state === "complete") {
12311                clearInterval(elapsedTimer);
12312                lcSetPhase("Done");
12313                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
12314              } else if (data.state === "failed") {
12315                lcShowError(data.message);
12316              } else if (data.state === "cancelled") {
12317                lcShowCancelled();
12318              } else {
12319                var s = Math.floor((Date.now() - startTime) / 1000);
12320                if (s > 90 && !warnShown) {
12321                  warnShown = true;
12322                  var w = document.getElementById("lc-warn");
12323                  if (w) w.classList.remove("hidden");
12324                }
12325                lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
12326                setTimeout(function() { lcPoll(waitId); }, 1500);
12327              }
12328            })
12329            .catch(function() {
12330              pollRetries++;
12331              if (pollRetries >= 5) {
12332                lcShowError("Lost connection to server. Reload to check status.");
12333              } else {
12334                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
12335              }
12336            });
12337        }
12338
12339        var params = new URLSearchParams(formData);
12340        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
12341          .then(function(r) {
12342            var waitId = r.headers.get("x-wait-id");
12343            if (!waitId) { window.location.href = "/scan"; return; }
12344            activeWaitId = waitId;
12345            setTimeout(function() { lcPoll(waitId); }, 1500);
12346          })
12347          .catch(function(err) {
12348            lcShowError("Could not reach server: " + (err.message || err));
12349          });
12350      }
12351
12352      if (quickScanBtn) {
12353        quickScanBtn.addEventListener("click", function () {
12354          var pathVal = pathInput ? pathInput.value.trim() : "";
12355          if (!pathVal) {
12356            alert("Please enter or browse to a project path first.");
12357            return;
12358          }
12359          quickScanBtn.disabled = true;
12360          quickScanBtn.textContent = "Scanning...";
12361          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
12362          startAsyncAnalysis(new FormData(form));
12363        });
12364      }
12365
12366      var mixedPolicyInfo = {
12367        code_only: {
12368          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.",
12369          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'
12370        },
12371        code_and_comment: {
12372          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.",
12373          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'
12374        },
12375        comment_only: {
12376          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.",
12377          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'
12378        },
12379        separate_mixed_category: {
12380          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.",
12381          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'
12382        }
12383      };
12384
12385      var scanPresetInfo = {
12386        balanced: {
12387          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.",
12388          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
12389          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
12390          note: "Best when you want a stable local overview before making deeper adjustments.",
12391          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12392        },
12393        code_focused: {
12394          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
12395          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
12396          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
12397          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
12398          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12399        },
12400        comment_audit: {
12401          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
12402          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
12403          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
12404          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
12405          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
12406        },
12407        deep_review: {
12408          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
12409          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
12410          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
12411          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
12412          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
12413        }
12414      };
12415
12416      var artifactPresetInfo = {
12417        review: {
12418          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.",
12419          chips: ["HTML", "PDF"],
12420          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
12421        },
12422        full: {
12423          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.",
12424          chips: ["HTML", "PDF", "JSON"],
12425          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
12426        },
12427        html_only: {
12428          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.",
12429          chips: ["HTML only", "Fast local review"],
12430          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
12431        },
12432        machine: {
12433          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
12434          chips: ["HTML", "JSON"],
12435          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
12436        }
12437      };
12438
12439      function applyTheme(theme) {
12440        if (theme === "dark") document.body.classList.add("dark-theme");
12441        else document.body.classList.remove("dark-theme");
12442      }
12443
12444      function loadSavedTheme() {
12445        var saved = null;
12446        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
12447        applyTheme(saved === "dark" ? "dark" : "light");
12448      }
12449
12450      function updateScrollProgress() {
12451        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
12452        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
12453        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
12454        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
12455        var step = Math.min(Math.max(currentStep, 1), 4);
12456        var base = stepBase[step];
12457        var end  = stepEnd[step];
12458
12459        var scrollFrac = 0;
12460        var activePanel = document.querySelector(".wizard-step.active");
12461        if (activePanel) {
12462          var scrollTop = window.scrollY || window.pageYOffset || 0;
12463          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
12464          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
12465          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
12466          var scrolled = scrollTop + viewH - panelTop;
12467          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
12468        }
12469
12470        var percent = Math.round(base + (end - base) * scrollFrac);
12471        percent = Math.min(end, Math.max(base, percent));
12472        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
12473        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
12474      }
12475
12476      function updateWizardProgress() {
12477        updateScrollProgress();
12478      }
12479
12480      var stepDescriptions = [
12481        "Choose a project folder, apply scope filters, and preview which files will be counted.",
12482        "Configure how mixed code-plus-comment lines and docstrings are classified.",
12483        "Pick your output formats, scan preset, and where reports are saved.",
12484        "Review all settings and launch the analysis."
12485      ];
12486
12487      function updateStepNav(step) {
12488        var infoLabel = document.getElementById("step-nav-info-label");
12489        var infoDesc  = document.getElementById("step-nav-info-desc");
12490        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
12491        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
12492      }
12493
12494      function updateSidebarSummary() {
12495        var sumPath    = document.getElementById("sum-path");
12496        var sumPreset  = document.getElementById("sum-preset");
12497        var sumOutput  = document.getElementById("sum-output");
12498        var sidebarSummary = document.getElementById("sidebar-summary");
12499        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
12500        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
12501        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
12502        if (sumPath)   sumPath.textContent   = pathVal   || "—";
12503        if (sumPreset) sumPreset.textContent = presetVal || "—";
12504        if (sumOutput) sumOutput.textContent = outputVal || "—";
12505        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
12506      }
12507
12508      function setStep(step, pushHistory) {
12509        currentStep = step;
12510        stepPanels.forEach(function (panel) {
12511          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
12512        });
12513        stepButtons.forEach(function (button) {
12514          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
12515        });
12516        var layoutEl = document.querySelector(".layout");
12517        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
12518        updateWizardProgress();
12519        updateStepNav(step);
12520        stepButtons.forEach(function(btn) {
12521          var t = Number(btn.getAttribute("data-step-target"));
12522          btn.classList.toggle("done", t < step);
12523        });
12524        updateSidebarSummary();
12525
12526        if (pushHistory !== false) {
12527          try {
12528            history.pushState({ wizardStep: step }, "", "#step" + step);
12529          } catch (e) {}
12530        }
12531
12532        window.scrollTo({ top: 0, behavior: "instant" });
12533      }
12534
12535      window.addEventListener("popstate", function (e) {
12536        if (e.state && e.state.wizardStep) {
12537          setStep(e.state.wizardStep, false);
12538        } else {
12539          var hashMatch = location.hash.match(/^#step([1-4])$/);
12540          if (hashMatch) setStep(Number(hashMatch[1]), false);
12541        }
12542      });
12543
12544      function inferTitleFromPath(value) {
12545        if (!value) return "project";
12546        var cleaned = value.replace(/[\/\\]+$/, "");
12547        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
12548        return parts.length ? parts[parts.length - 1] : value;
12549      }
12550
12551      function updateReportTitleFromPath() {
12552        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
12553        if (!reportTitleTouched) {
12554          reportTitleInput.value = inferred;
12555        }
12556        var title = reportTitleInput.value || inferred;
12557        if (liveReportTitle) liveReportTitle.textContent = title;
12558        if (reportTitlePreview) reportTitlePreview.textContent = title;
12559        document.title = "OxideSLOC | " + title;
12560
12561        var projectPath = (pathInput.value || "").trim();
12562        if (navProjectPill && navProjectTitle) {
12563          if (projectPath.length > 0) {
12564            navProjectTitle.textContent = inferred;
12565            navProjectPill.classList.add("visible");
12566          } else {
12567            navProjectTitle.textContent = "";
12568            navProjectPill.classList.remove("visible");
12569          }
12570        }
12571      }
12572
12573      function updateMixedPolicyUI() {
12574        var key = mixedLinePolicy.value || "code_only";
12575        var info = mixedPolicyInfo[key];
12576        document.getElementById("mixed-policy-description").textContent = info.description;
12577        document.getElementById("mixed-policy-example").textContent = info.example;
12578      }
12579
12580      function updatePythonDocstringUI() {
12581        var checked = !!pythonDocstrings.checked;
12582        document.getElementById("python-docstring-example").textContent = checked
12583          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
12584          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
12585        document.getElementById("python-docstring-live-help").textContent = checked
12586          ? "Enabled: docstrings contribute to comment-style totals."
12587          : "Disabled: docstrings are not counted as comment content.";
12588      }
12589
12590      function renderPresetChips(targetId, chips) {
12591        var target = document.getElementById(targetId);
12592        if (!target) return;
12593        target.innerHTML = (chips || []).map(function (chip) {
12594          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
12595        }).join('');
12596      }
12597
12598      function updatePresetDescriptions() {
12599        var scanInfo = scanPresetInfo[scanPreset.value];
12600        var artifactInfo = artifactPresetInfo[artifactPreset.value];
12601        document.getElementById("scan-preset-description").textContent = scanInfo.description;
12602        document.getElementById("scan-preset-example").textContent = scanInfo.example;
12603        document.getElementById("scan-preset-note").textContent = scanInfo.note;
12604        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
12605        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
12606        renderPresetChips("scan-preset-summary", scanInfo.chips);
12607        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
12608      }
12609
12610      function applyScanPreset() {
12611        var info = scanPresetInfo[scanPreset.value];
12612        if (!info || !info.apply) return;
12613        mixedLinePolicy.value = info.apply.mixed;
12614        pythonDocstrings.checked = !!info.apply.docstrings;
12615        document.getElementById("generated_file_detection").value = info.apply.generated;
12616        document.getElementById("minified_file_detection").value = info.apply.minified;
12617        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
12618        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
12619        document.getElementById("binary_file_behavior").value = info.apply.binary;
12620        updateMixedPolicyUI();
12621        updatePythonDocstringUI();
12622      }
12623
12624      function applyArtifactPreset() {
12625        var enabled = { html: false, pdf: false };
12626        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
12627        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
12628        if (artifactPreset.value === "html_only") { enabled.html = true; }
12629        if (artifactPreset.value === "machine") { enabled.html = true; }
12630
12631        artifactCards.forEach(function (card) {
12632          var artifact = card.getAttribute("data-artifact");
12633          if (artifact === "json") return;
12634          var checked = !!enabled[artifact];
12635          var checkbox = card.querySelector(".artifact-checkbox");
12636          checkbox.checked = checked;
12637          card.classList.toggle("selected", checked);
12638        });
12639      }
12640
12641      function toggleArtifactCard(card) {
12642        var checkbox = card.querySelector(".artifact-checkbox");
12643        checkbox.checked = !checkbox.checked;
12644        card.classList.toggle("selected", checkbox.checked);
12645      }
12646
12647      function updateReview() {
12648        var scanSummary = document.getElementById("review-scan-summary");
12649        var countSummary = document.getElementById("review-count-summary");
12650        var artifactSummary = document.getElementById("review-artifact-summary");
12651        var outputSummary = document.getElementById("review-output-summary");
12652        var previewSummary = document.getElementById("review-preview-summary");
12653        var readinessSummary = document.getElementById("review-readiness-summary");
12654        var includeText = document.getElementById("include_globs").value.trim();
12655        var excludeText = document.getElementById("exclude_globs").value.trim();
12656        var sidePathPreview = document.getElementById("side-path-preview");
12657        var sideOutputPreview = document.getElementById("side-output-preview");
12658        var sideTitlePreview = document.getElementById("side-title-preview");
12659
12660        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
12661        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
12662        if (sideTitlePreview) {
12663          var rt = document.getElementById("report_title");
12664          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
12665        }
12666
12667        scanSummary.innerHTML = ""
12668          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
12669          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
12670          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
12671
12672        countSummary.innerHTML = ""
12673          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
12674          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
12675          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
12676          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
12677          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
12678          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
12679          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
12680          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
12681
12682        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
12683        artifactSummary.innerHTML = ""
12684          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
12685          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
12686
12687        outputSummary.innerHTML = ""
12688          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
12689          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
12690
12691        if (previewSummary) {
12692          if (GIT_MODE) {
12693            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>';
12694          } else {
12695          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
12696          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
12697          var statMap = {};
12698          statButtons.forEach(function (button) {
12699            var valueNode = button.querySelector('.scope-stat-value');
12700            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
12701          });
12702          previewSummary.innerHTML = ''
12703            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
12704            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
12705            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
12706            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
12707            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
12708            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
12709
12710          if (readinessSummary) {
12711            var selectedArtifactsCount = selectedArtifacts.length;
12712            readinessSummary.innerHTML = ''
12713              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
12714              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
12715              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
12716              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
12717          }
12718          } // end else (non-GIT_MODE)
12719        }
12720      }
12721
12722      function escapeHtml(value) {
12723        return String(value)
12724          .replace(/&/g, "&amp;")
12725          .replace(/</g, "&lt;")
12726          .replace(/>/g, "&gt;")
12727          .replace(/"/g, "&quot;")
12728          .replace(/'/g, "&#39;");
12729      }
12730
12731      function isPythonVisible() {
12732        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
12733      }
12734
12735      function syncPythonVisibility() {
12736        var html = previewPanel.textContent || "";
12737        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
12738        pythonWraps.forEach(function (node) {
12739          node.classList.toggle("hidden", !hasPython);
12740        });
12741      }
12742
12743      function attachPreviewInteractions() {
12744        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
12745        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
12746        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
12747        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
12748        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
12749        var searchInput = previewPanel.querySelector("#explorer-search");
12750        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
12751        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
12752        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
12753        var activeFilter = "all";
12754        var activeLanguage = "";
12755        var searchTerm = "";
12756        var currentSortKey = null;
12757        var currentSortOrder = "asc";
12758        var childRows = {};
12759
12760        rows.forEach(function (row) {
12761          var parentId = row.getAttribute("data-parent-id") || "";
12762          var rowId = row.getAttribute("data-row-id") || "";
12763          if (!childRows[parentId]) childRows[parentId] = [];
12764          childRows[parentId].push(rowId);
12765        });
12766
12767        function rowById(id) {
12768          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
12769        }
12770
12771        function hasCollapsedAncestor(row) {
12772          var parentId = row.getAttribute("data-parent-id");
12773          while (parentId) {
12774            var parent = rowById(parentId);
12775            if (!parent) break;
12776            if (parent.getAttribute("data-expanded") === "false") return true;
12777            parentId = parent.getAttribute("data-parent-id");
12778          }
12779          return false;
12780        }
12781
12782        function updateToggleGlyph(row) {
12783          var toggle = row.querySelector(".tree-toggle");
12784          if (!toggle) return;
12785          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
12786        }
12787
12788        function rowSortValue(row, key) {
12789          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
12790        }
12791
12792        function updateSortButtons() {
12793          sortButtons.forEach(function (button) {
12794            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
12795            var indicator = button.querySelector(".tree-sort-indicator");
12796            button.classList.toggle("active", isActive);
12797            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
12798            if (indicator) {
12799              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
12800            }
12801          });
12802        }
12803
12804        function sortSiblingRows() {
12805          if (!treeContainer) {
12806            updateSortButtons();
12807            return;
12808          }
12809
12810          var rowMap = {};
12811          var childrenMap = {};
12812          rows.forEach(function (row) {
12813            var rowId = row.getAttribute("data-row-id");
12814            var parentId = row.getAttribute("data-parent-id") || "";
12815            rowMap[rowId] = row;
12816            if (!childrenMap[parentId]) childrenMap[parentId] = [];
12817            childrenMap[parentId].push(rowId);
12818          });
12819
12820          Object.keys(childrenMap).forEach(function (parentId) {
12821            if (!parentId) return;
12822            childrenMap[parentId].sort(function (a, b) {
12823              var rowA = rowMap[a];
12824              var rowB = rowMap[b];
12825              if (!currentSortKey) {
12826                return Number(a) - Number(b);
12827              }
12828              var valueA = rowSortValue(rowA, currentSortKey);
12829              var valueB = rowSortValue(rowB, currentSortKey);
12830              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
12831              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
12832              var fallbackA = rowSortValue(rowA, "name");
12833              var fallbackB = rowSortValue(rowB, "name");
12834              if (fallbackA < fallbackB) return -1;
12835              if (fallbackA > fallbackB) return 1;
12836              return Number(a) - Number(b);
12837            });
12838          });
12839
12840          var orderedIds = [];
12841          function pushChildren(parentId) {
12842            (childrenMap[parentId] || []).forEach(function (childId) {
12843              orderedIds.push(childId);
12844              pushChildren(childId);
12845            });
12846          }
12847
12848          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
12849            orderedIds.push(topId);
12850            pushChildren(topId);
12851          });
12852
12853          orderedIds.forEach(function (id) {
12854            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
12855          });
12856          updateSortButtons();
12857        }
12858
12859        function updateLanguageButtons() {
12860          languageButtons.forEach(function (button) {
12861            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
12862            var isActive = languageValue === activeLanguage;
12863            button.classList.toggle("active", isActive);
12864          });
12865        }
12866
12867        function rowSelfMatches(row) {
12868          var kind = row.getAttribute("data-kind");
12869          var status = row.getAttribute("data-status");
12870          var language = (row.getAttribute("data-language") || "").toLowerCase();
12871          var name = row.getAttribute("data-name-lower") || "";
12872          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
12873          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
12874          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
12875          var passesLanguage = !activeLanguage || language === activeLanguage;
12876          return passesFilter && passesSearch && passesLanguage;
12877        }
12878
12879        function hasMatchingDescendant(rowId) {
12880          return (childRows[rowId] || []).some(function (childId) {
12881            var childRow = rowById(childId);
12882            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
12883          });
12884        }
12885
12886        function rowMatches(row) {
12887          if (rowSelfMatches(row)) return true;
12888          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
12889        }
12890
12891        function resetViewState() {
12892          activeFilter = "all";
12893          activeLanguage = "";
12894          searchTerm = "";
12895          currentSortKey = null;
12896          currentSortOrder = "asc";
12897          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
12898          if (searchInput) searchInput.value = "";
12899          if (filterSelect) filterSelect.value = "all";
12900          updateLanguageButtons();
12901        }
12902
12903        function applyVisibility() {
12904          rows.forEach(function (row) {
12905            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
12906            row.classList.toggle("hidden-by-filter", !visible);
12907            row.style.display = visible ? "grid" : "none";
12908          });
12909          buttons.forEach(function (button) {
12910            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
12911          });
12912          if (filterSelect) filterSelect.value = activeFilter;
12913        }
12914
12915        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
12916        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
12917        var originalStats = {};
12918        buttons.forEach(function (btn) {
12919          var f = btn.getAttribute('data-filter');
12920          var v = btn.querySelector('.scope-stat-value');
12921          if (f && v) originalStats[f] = v.textContent;
12922        });
12923
12924        function applySubmoduleStats(statsJson) {
12925          try {
12926            var s = JSON.parse(statsJson);
12927            buttons.forEach(function (btn) {
12928              var f = btn.getAttribute('data-filter');
12929              var v = btn.querySelector('.scope-stat-value');
12930              if (!v) return;
12931              if (f === 'dir') v.textContent = s.dirs;
12932              else if (f === 'file') v.textContent = s.files;
12933              else if (f === 'supported') v.textContent = s.supported;
12934              else if (f === 'skipped') v.textContent = s.skipped;
12935              else if (f === 'unsupported') v.textContent = s.unsupported;
12936            });
12937          } catch (e) {}
12938        }
12939
12940        function restoreBaseRepoStats() {
12941          buttons.forEach(function (btn) {
12942            var f = btn.getAttribute('data-filter');
12943            var v = btn.querySelector('.scope-stat-value');
12944            if (v && originalStats[f]) v.textContent = originalStats[f];
12945          });
12946          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
12947          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
12948        }
12949
12950        submoduleChips.forEach(function (chip) {
12951          chip.addEventListener('click', function () {
12952            var statsJson = chip.getAttribute('data-sub-stats');
12953            if (!statsJson) return;
12954            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
12955            chip.classList.add('active');
12956            applySubmoduleStats(statsJson);
12957            if (baseRepoBtn) baseRepoBtn.style.display = '';
12958          });
12959        });
12960
12961        if (baseRepoBtn) {
12962          baseRepoBtn.addEventListener('click', function () {
12963            restoreBaseRepoStats();
12964            resetViewState();
12965            sortSiblingRows();
12966            applyVisibility();
12967          });
12968        }
12969
12970        buttons.forEach(function (button) {
12971          button.addEventListener("click", function () {
12972            var filterValue = button.getAttribute("data-filter") || "all";
12973            if (filterValue === "reset-view") {
12974              restoreBaseRepoStats();
12975              resetViewState();
12976              sortSiblingRows();
12977              applyVisibility();
12978              return;
12979            }
12980            activeFilter = filterValue;
12981            applyVisibility();
12982          });
12983        });
12984
12985        rows.forEach(function (row) {
12986          updateToggleGlyph(row);
12987          var toggle = row.querySelector(".tree-toggle");
12988          if (toggle) {
12989            toggle.addEventListener("click", function () {
12990              var expanded = row.getAttribute("data-expanded") !== "false";
12991              row.setAttribute("data-expanded", expanded ? "false" : "true");
12992              updateToggleGlyph(row);
12993              applyVisibility();
12994            });
12995          }
12996        });
12997
12998        actionButtons.forEach(function (button) {
12999          button.addEventListener("click", function () {
13000            var action = button.getAttribute("data-explorer-action");
13001            if (action === "expand-all") {
13002              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
13003            } else if (action === "collapse-all") {
13004              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
13005            } else if (action === "clear-filters") {
13006              resetViewState();
13007            }
13008            sortSiblingRows();
13009            applyVisibility();
13010          });
13011        });
13012
13013        if (filterSelect) {
13014          filterSelect.addEventListener("change", function () {
13015            activeFilter = filterSelect.value || "all";
13016            applyVisibility();
13017          });
13018        }
13019
13020        languageButtons.forEach(function (button) {
13021          button.addEventListener("click", function () {
13022            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
13023            updateLanguageButtons();
13024            applyVisibility();
13025          });
13026        });
13027
13028        sortButtons.forEach(function (button) {
13029          button.addEventListener("click", function () {
13030            var sortKey = button.getAttribute("data-sort-key");
13031            if (currentSortKey === sortKey) {
13032              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
13033            } else {
13034              currentSortKey = sortKey;
13035              currentSortOrder = "asc";
13036            }
13037            sortSiblingRows();
13038            applyVisibility();
13039          });
13040        });
13041
13042        if (searchInput) {
13043          searchInput.addEventListener("input", function () {
13044            searchTerm = searchInput.value.trim().toLowerCase();
13045            applyVisibility();
13046          });
13047        }
13048
13049        updateLanguageButtons();
13050        sortSiblingRows();
13051        applyVisibility();
13052      }
13053
13054      function loadPreview() {
13055        if (!previewPanel || !pathInput) return;
13056        if (GIT_MODE) {
13057          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>';
13058          return;
13059        }
13060        var path = pathInput.value.trim();
13061        var zeroWarn = document.getElementById('zero-files-warning');
13062        if (!path) {
13063          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
13064          if (zeroWarn) zeroWarn.style.display = 'none';
13065          return;
13066        }
13067        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
13068        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
13069        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
13070        var previewUrl = "/preview?path=" + encodeURIComponent(path)
13071          + "&include_globs=" + encodeURIComponent(includeValue)
13072          + "&exclude_globs=" + encodeURIComponent(excludeValue);
13073        fetch(previewUrl)
13074          .then(function (response) { return response.text(); })
13075          .then(function (html) {
13076            previewPanel.innerHTML = html;
13077            attachPreviewInteractions();
13078            syncPythonVisibility();
13079            updateReview();
13080            setTimeout(collapseLanguagePills, 50);
13081            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
13082            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
13083            var sizeText = document.getElementById('project-size-text');
13084            var sizeBtn = document.getElementById('project-size-btn');
13085            // In server mode with upload sizes available, keep the compressed/original pair.
13086            if (SERVER_MODE && window._lastUploadSizes) {
13087              var us = window._lastUploadSizes;
13088              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
13089                ' · Compressed: ' + fmtBytes(us.compressed_bytes);
13090              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
13091                ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
13092            } else if (sizeText && projectSize) {
13093              sizeText.textContent = 'Project size: ' + projectSize;
13094              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
13095            } else if (sizeText) {
13096              sizeText.textContent = 'Project size: —';
13097            }
13098            if (zeroWarn) {
13099              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
13100              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
13101              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
13102              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
13103              if (supportedCount === 0 && fileCount > 0) {
13104                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).';
13105                zeroWarn.style.display = '';
13106              } else {
13107                zeroWarn.style.display = 'none';
13108              }
13109            }
13110          })
13111          .catch(function (err) {
13112            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
13113          });
13114      }
13115
13116      function pickDirectory(targetInput, kind) {
13117        if (SERVER_MODE) {
13118          if (kind === 'output') {
13119            showBannerToast(
13120              'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
13121              false,
13122              { top: true, icon: '📁' }
13123            );
13124            return;
13125          }
13126          var inputEl = kind === 'coverage'
13127            ? document.getElementById('cov-upload-input')
13128            : document.getElementById('dir-upload-input');
13129          if (!inputEl) return;
13130          inputEl.onchange = function () {
13131            var files = inputEl.files;
13132            if (!files || files.length === 0) return;
13133            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
13134            if (browseBtn) browseBtn.disabled = true;
13135
13136            function fileToBase64(file) {
13137              return new Promise(function (resolve, reject) {
13138                var reader = new FileReader();
13139                reader.onload = function () {
13140                  var b64 = reader.result.split(',')[1];
13141                  resolve(b64);
13142                };
13143                reader.onerror = reject;
13144                reader.readAsDataURL(file);
13145              });
13146            }
13147
13148            if (kind === 'coverage') {
13149              var f = files[0];
13150              if (previewPanel && targetInput === pathInput)
13151                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
13152              fileToBase64(f).then(function (b64) {
13153                return fetch('/api/upload-file', {
13154                  method: 'POST',
13155                  headers: { 'Content-Type': 'application/json' },
13156                  body: JSON.stringify({ filename: f.name, content: b64 })
13157                }).then(function (r) { return r.json(); });
13158              })
13159                .then(function (d) {
13160                  if (d && d.tmp_path) {
13161                    if (coverageInput) coverageInput.value = d.tmp_path;
13162                    setCovStatus('idle');
13163                  } else if (d && d.error) { showBannerToast(d.error, true); }
13164                })
13165                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
13166                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
13167            } else {
13168              // ── Filter to source-code files only ─────────────────────────
13169              // Binary, generated, and dependency files (node_modules, .git,
13170              // build artifacts) are skipped so they are never uploaded.
13171              var CODE_EXTS = new Set([
13172                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13173                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13174                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13175                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13176                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13177                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
13178                'tf','hcl','proto','thrift','avsc','graphql','gql'
13179              ]);
13180              var codeFiles = [];
13181              for (var i = 0; i < files.length; i++) {
13182                var f = files[i];
13183                var name = f.name;
13184                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
13185                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
13186                  codeFiles.push(f); continue;
13187                }
13188                var dot = name.lastIndexOf('.');
13189                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
13190              }
13191              // Collect specific .git metadata files for server-side git detection.
13192              // These have no source extension so they are excluded by the loop above,
13193              // but the server needs them to read branch/commit/author without running git.
13194              var gitMetaFiles = [];
13195              for (var i = 0; i < files.length; i++) {
13196                var f = files[i];
13197                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
13198                var gitIdx = rp.indexOf('/.git/');
13199                if (gitIdx < 0) continue;
13200                var gitRel = rp.slice(gitIdx + 1);
13201                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
13202                    gitRel === '.git/logs/HEAD' ||
13203                    gitRel.startsWith('.git/refs/heads/') ||
13204                    gitRel.startsWith('.git/refs/tags/')) {
13205                  gitMetaFiles.push(f);
13206                }
13207              }
13208              var uploadFiles = codeFiles.concat(gitMetaFiles);
13209              var total = files.length;
13210              var kept = codeFiles.length;
13211              if (kept === 0) {
13212                if (previewPanel && targetInput === pathInput)
13213                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
13214                if (browseBtn) browseBtn.disabled = false;
13215                inputEl.value = '';
13216                return;
13217              }
13218
13219              // ── Helper: apply upload result to UI ────────────────────────
13220              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
13221              function applyUploadResult(tmpPath, sizes) {
13222                targetInput.value = tmpPath;
13223                scrollInputToEnd(targetInput);
13224                if (sizes && SERVER_MODE) {
13225                  window._lastUploadSizes = sizes;
13226                  // Immediately show both sizes before preview loads.
13227                  var sizeText = document.getElementById('project-size-text');
13228                  var sizeBtn = document.getElementById('project-size-btn');
13229                  if (sizeText) {
13230                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13231                      ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13232                  }
13233                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13234                    ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13235                }
13236                if (targetInput === pathInput) {
13237                  updateReportTitleFromPath();
13238                  autoSetOutputDir(tmpPath);
13239                  fetchProjectHistory(tmpPath);
13240                  loadPreview();
13241                  suggestCoverageFile(tmpPath);
13242                }
13243                updateReview();
13244                if (browseBtn) browseBtn.disabled = false;
13245                inputEl.value = '';
13246              }
13247
13248              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
13249              if (typeof CompressionStream !== 'undefined') {
13250                if (previewPanel && targetInput === pathInput)
13251                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13252
13253                // Build a minimal POSIX ustar tar header for a single file entry.
13254                function buildUstarHeader(filePath, fileSize) {
13255                  var BLOCK = 512;
13256                  var hdr = new Uint8Array(BLOCK);
13257                  var enc = new TextEncoder();
13258                  function wStr(off, len, s) {
13259                    var b = enc.encode(s);
13260                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
13261                  }
13262                  function wOct(off, len, val) {
13263                    var s = val.toString(8);
13264                    while (s.length < len - 1) s = '0' + s;
13265                    wStr(off, len, s + '\0');
13266                  }
13267                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
13268                  var name = filePath, prefix = '';
13269                  if (filePath.length > 99) {
13270                    var split = filePath.lastIndexOf('/', 154);
13271                    if (split > 0 && filePath.length - split - 1 <= 99) {
13272                      prefix = filePath.substring(0, split);
13273                      name   = filePath.substring(split + 1);
13274                    } else { name = filePath.substring(0, 99); }
13275                  }
13276                  wStr(0,   100, name);          // name
13277                  wOct(100,   8, 0o000644);      // mode
13278                  wOct(108,   8, 0);             // uid
13279                  wOct(116,   8, 0);             // gid
13280                  wOct(124,  12, fileSize);      // size
13281                  wOct(136,  12, 0);             // mtime (epoch)
13282                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
13283                  hdr[156] = 48;                 // type flag '0' = regular file
13284                  wStr(157, 100, '');            // linkname
13285                  wStr(257,   6, 'ustar');       // magic
13286                  wStr(263,   2, '00');          // version
13287                  wStr(265,  32, '');            // uname
13288                  wStr(297,  32, '');            // gname
13289                  wOct(329,   8, 0);             // devmajor
13290                  wOct(337,   8, 0);             // devminor
13291                  wStr(345, 155, prefix);        // prefix
13292                  // Compute checksum (sum of all bytes, placeholder = 32).
13293                  var chk = 0;
13294                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13295                  var cs = chk.toString(8);
13296                  while (cs.length < 6) cs = '0' + cs;
13297                  wStr(148, 8, cs + '\0 ');
13298                  return hdr;
13299                }
13300
13301                // Build tar.gz one file at a time, piping through CompressionStream.
13302                // RAM usage = compressed output buffer + one file at a time.
13303                (async function () {
13304                  try {
13305                    var BLOCK = 512;
13306                    var cs     = new CompressionStream('gzip');
13307                    var writer = cs.writable.getWriter();
13308                    var chunks = [];
13309                    var reader = cs.readable.getReader();
13310                    var collecting = (async function () {
13311                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
13312                    })();
13313
13314                    for (var i = 0; i < uploadFiles.length; i++) {
13315                      var file = uploadFiles[i];
13316                      var path = file.webkitRelativePath || file.name;
13317                      var buf  = await file.arrayBuffer();
13318                      var data = new Uint8Array(buf);
13319                      // Header block
13320                      await writer.write(buildUstarHeader(path, data.length));
13321                      // Data padded to 512-byte boundary
13322                      if (data.length > 0) {
13323                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
13324                        var block  = new Uint8Array(padded);
13325                        block.set(data);
13326                        await writer.write(block);
13327                      }
13328                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
13329                        if (previewPanel && targetInput === pathInput)
13330                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13331                      }
13332                    }
13333                    // End-of-archive: two 512-byte zero blocks
13334                    await writer.write(new Uint8Array(BLOCK * 2));
13335                    await writer.close();
13336                    await collecting;
13337
13338                    var blob = new Blob(chunks, { type: 'application/gzip' });
13339                    var sizeMB = (blob.size / 1048576).toFixed(1);
13340                    if (previewPanel && targetInput === pathInput)
13341                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
13342
13343                    var resp = await fetch('/api/upload-tarball', {
13344                      method: 'POST',
13345                      headers: { 'Content-Type': 'application/gzip' },
13346                      body: blob
13347                    });
13348                    var d = await resp.json();
13349                    if (d && d.tmp_path) {
13350                      applyUploadResult(d.tmp_path, {
13351                        compressed_bytes: d.compressed_bytes || 0,
13352                        original_bytes: d.original_bytes || 0
13353                      });
13354                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13355                  } catch (e) {
13356                    showBannerToast('Upload failed: ' + String(e), true);
13357                    if (browseBtn) browseBtn.disabled = false;
13358                    inputEl.value = '';
13359                  }
13360                })();
13361
13362              } else {
13363                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
13364                // Used only on browsers that lack CompressionStream (pre-2023).
13365                var BATCH = 200;
13366                var batches = [];
13367                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
13368                var totalBatches = batches.length;
13369                if (previewPanel && targetInput === pathInput)
13370                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
13371
13372                function sendBatch(idx, currentUploadId, lastTmpPath) {
13373                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
13374                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
13375                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
13376                  Promise.all(batches[idx].map(function (file) {
13377                    return fileToBase64(file).then(function (b64) {
13378                      return { path: file.webkitRelativePath || file.name, content: b64 };
13379                    });
13380                  })).then(function (fileList) {
13381                    var body = { files: fileList };
13382                    if (currentUploadId) body.upload_id = currentUploadId;
13383                    return fetch('/api/upload-directory', {
13384                      method: 'POST', headers: { 'Content-Type': 'application/json' },
13385                      body: JSON.stringify(body)
13386                    }).then(function (r) { return r.json(); });
13387                  }).then(function (d) {
13388                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
13389                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
13390                  }).catch(function (e) {
13391                    showBannerToast('Upload failed: ' + String(e), true);
13392                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
13393                  });
13394                }
13395                sendBatch(0, null, '');
13396              }
13397            }
13398          };
13399          inputEl.click();
13400          return;
13401        }
13402
13403        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
13404        if (browseButton) browseButton.disabled = true;
13405
13406        if (previewPanel && targetInput === pathInput) {
13407          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
13408        }
13409
13410        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
13411          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
13412          .then(function (data) {
13413            if (data && data.selected_path) {
13414              targetInput.value = data.selected_path;
13415              scrollInputToEnd(targetInput);
13416
13417              if (targetInput === pathInput) {
13418                updateReportTitleFromPath();
13419                autoSetOutputDir(data.selected_path);
13420                fetchProjectHistory(data.selected_path);
13421                loadPreview();
13422                suggestCoverageFile(data.selected_path);
13423              }
13424
13425              updateReview();
13426            } else if (targetInput === pathInput) {
13427              loadPreview();
13428            }
13429          })
13430          .catch(function () {
13431            window.alert("Directory picker request failed.");
13432            if (previewPanel && targetInput === pathInput) {
13433              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
13434            }
13435          })
13436          .finally(function () {
13437            if (browseButton) browseButton.disabled = false;
13438          });
13439      }
13440
13441      if (themeToggle) {
13442        themeToggle.addEventListener("click", function () {
13443          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
13444          applyTheme(nextTheme);
13445          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
13446        });
13447      }
13448
13449      stepButtons.forEach(function (button) {
13450        button.addEventListener("click", function () {
13451          setStep(Number(button.getAttribute("data-step-target")));
13452        });
13453      });
13454
13455      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
13456        button.addEventListener("click", function () {
13457          setStep(Number(button.getAttribute("data-step-target")) || 1);
13458        });
13459      });
13460
13461      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
13462        button.addEventListener("click", function () {
13463          updateReview();
13464          setStep(Number(button.getAttribute("data-next")));
13465        });
13466      });
13467
13468      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
13469        button.addEventListener("click", function () {
13470          setStep(Number(button.getAttribute("data-prev")));
13471        });
13472      });
13473
13474      document.addEventListener("keydown", function (e) {
13475        var tag = (document.activeElement || {}).tagName || "";
13476        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
13477        if (e.altKey || e.ctrlKey || e.metaKey) return;
13478        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
13479        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
13480      });
13481
13482      if (useSamplePath) {
13483        useSamplePath.addEventListener("click", function () {
13484          pathInput.value = "tests/fixtures/basic";
13485          updateReportTitleFromPath();
13486          autoSetOutputDir("tests/fixtures/basic");
13487          loadPreview();
13488          suggestCoverageFile("tests/fixtures/basic");
13489        });
13490      }
13491
13492      if (useDefaultOutput) {
13493        useDefaultOutput.addEventListener("click", function () {
13494          delete outputDirInput.dataset.userEdited;
13495          autoSetOutputDir(pathInput ? pathInput.value : "");
13496          updateReview();
13497        });
13498      }
13499
13500      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
13501      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
13502
13503      // ── Drag-and-drop directory upload (server mode only) ─────────────────
13504      // Dropping a folder onto the path field bypasses Chrome's
13505      // "Upload X files to this site?" confirmation dialog.
13506      async function readDirRecursively(dirEntry, basePath) {
13507        var reader = dirEntry.createReader();
13508        var all = [];
13509        for (;;) {
13510          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
13511          if (!batch.length) break;
13512          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
13513        }
13514        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
13515        var out = [];
13516        for (var i = 0; i < all.length; i++) {
13517          var sub = all[i];
13518          if (sub.isFile) {
13519            var f = await new Promise(function(res) { sub.file(res); });
13520            out.push({ file: f, path: basePath + '/' + sub.name });
13521          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
13522            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
13523            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
13524          }
13525        }
13526        return out;
13527      }
13528
13529      function setupPathDropZone() {
13530        if (!SERVER_MODE || !pathInput) return;
13531        var CODE_EXTS = new Set([
13532          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
13533          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
13534          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
13535          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
13536          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
13537          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
13538        ]);
13539        pathInput.addEventListener('dragover', function(e) {
13540          e.preventDefault();
13541          pathInput.classList.add('drag-over');
13542        });
13543        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
13544        pathInput.addEventListener('drop', function(e) {
13545          e.preventDefault();
13546          pathInput.classList.remove('drag-over');
13547          var items = e.dataTransfer.items;
13548          if (!items || !items.length) return;
13549          var dirEntry = null;
13550          for (var i = 0; i < items.length; i++) {
13551            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
13552            if (entry && entry.isDirectory) { dirEntry = entry; break; }
13553          }
13554          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
13555          var btn = browsePath;
13556          if (btn) btn.disabled = true;
13557          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
13558
13559          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
13560            var total = allEntries.length;
13561            var codeEntries = allEntries.filter(function(e) {
13562              var n = e.file.name;
13563              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
13564              var dot = n.lastIndexOf('.');
13565              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
13566            });
13567            var kept = codeEntries.length;
13568            if (kept === 0) {
13569              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
13570              if (btn) btn.disabled = false; return;
13571            }
13572
13573            function finish(tmpPath, sizes) {
13574              pathInput.value = tmpPath;
13575              scrollInputToEnd(pathInput);
13576              if (sizes) {
13577                window._lastUploadSizes = sizes;
13578                var sizeText = document.getElementById('project-size-text');
13579                var sizeBtn = document.getElementById('project-size-btn');
13580                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
13581                  ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
13582                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
13583                  ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
13584              }
13585              updateReportTitleFromPath();
13586              autoSetOutputDir(tmpPath);
13587              fetchProjectHistory(tmpPath);
13588              loadPreview();
13589              suggestCoverageFile(tmpPath);
13590              updateReview();
13591              if (btn) btn.disabled = false;
13592            }
13593
13594            if (typeof CompressionStream === 'undefined') {
13595              showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
13596              if (btn) btn.disabled = false; return;
13597            }
13598
13599            try {
13600              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
13601              var BLOCK = 512;
13602              var cs = new CompressionStream('gzip');
13603              var wtr = cs.writable.getWriter();
13604              var chunks = [];
13605              var rdr = cs.readable.getReader();
13606              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
13607
13608              function buildHdr(fp, sz) {
13609                var hdr = new Uint8Array(BLOCK);
13610                var enc = new TextEncoder();
13611                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]; }
13612                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
13613                var nm = fp, pfx = '';
13614                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); } }
13615                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
13616                for (var i = 148; i < 156; i++) hdr[i] = 32;
13617                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);
13618                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
13619                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
13620                return hdr;
13621              }
13622
13623              for (var i = 0; i < codeEntries.length; i++) {
13624                var ce = codeEntries[i];
13625                var buf = await ce.file.arrayBuffer();
13626                var data = new Uint8Array(buf);
13627                await wtr.write(buildHdr(ce.path, data.length));
13628                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
13629                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
13630                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
13631              }
13632              await wtr.write(new Uint8Array(BLOCK * 2));
13633              await wtr.close();
13634              await collecting;
13635
13636              var blob = new Blob(chunks, { type: 'application/gzip' });
13637              var sizeMB = (blob.size / 1048576).toFixed(1);
13638              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
13639              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
13640              var d = await resp.json();
13641              if (d && d.tmp_path) {
13642                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
13643              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
13644            } catch (err) {
13645              showBannerToast('Upload failed: ' + String(err), true);
13646              if (btn) btn.disabled = false;
13647            }
13648          }).catch(function(err) {
13649            showBannerToast('Could not read folder: ' + String(err), true);
13650            if (btn) btn.disabled = false;
13651          });
13652        });
13653      }
13654      setupPathDropZone();
13655      if (browseCoverage) {
13656        browseCoverage.addEventListener("click", function () {
13657          pickDirectory(coverageInput || pathInput, "coverage");
13658        });
13659      }
13660
13661      function setCovStatus(state, opts) {
13662        if (!covScanStatus) return;
13663        opts = opts || {};
13664        covScanStatus.className = "cov-scan-status cov-scan-" + state;
13665        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
13666        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>';
13667        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>';
13668        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>';
13669        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>';
13670        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
13671        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
13672        if (state === "scanning") {
13673          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
13674        } else if (state === "found") {
13675          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13676          html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
13677          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
13678          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
13679        } else if (state === "hint") {
13680          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
13681          html += '<div class="cov-scan-title">' + tb2 + ' detected &mdash; no coverage file found yet</div>';
13682          html += '<div class="cov-scan-sub">Generate one with:</div>';
13683          html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
13684        } else if (state === "none") {
13685          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
13686          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
13687        }
13688        html += '</div></div>';
13689        covScanStatus.innerHTML = html;
13690        if (state === "found") {
13691          var useBtn = covScanStatus.querySelector(".cov-scan-use");
13692          if (useBtn) useBtn.addEventListener("click", function () {
13693            if (coverageInput) coverageInput.value = "";
13694            covAutoFilled = false;
13695            setCovStatus("idle");
13696          });
13697        }
13698      }
13699
13700      function suggestCoverageFile(projectPath) {
13701        if (!coverageInput || !covScanStatus) return;
13702        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
13703        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
13704        clearTimeout(coverageSuggestTimer);
13705        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
13706        setCovStatus("scanning");
13707        coverageSuggestTimer = setTimeout(function () {
13708          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
13709            .then(function (r) { return r.json(); })
13710            .then(function (d) {
13711              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
13712              if (!d) { setCovStatus("none"); return; }
13713              if (d.found) {
13714                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
13715                setCovStatus("found", { found: d.found, tool: d.tool });
13716              } else if (d.tool && d.hint) {
13717                setCovStatus("hint", { tool: d.tool, hint: d.hint });
13718              } else {
13719                setCovStatus("none");
13720              }
13721            })
13722            .catch(function () { setCovStatus("idle"); });
13723        }, 600);
13724      }
13725
13726      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
13727
13728      if (coverageInput) coverageInput.addEventListener("input", function () {
13729        covAutoFilled = false;
13730        if (!this.value.trim()) setCovStatus("idle");
13731      });
13732
13733      // ── Language pill overflow: collapse to "+N more" chip ─────────────
13734      function collapseLanguagePills() {
13735        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
13736        rows.forEach(function(row) {
13737          // Remove any previous overflow chip
13738          var prev = row.querySelector('.lang-overflow-chip');
13739          if (prev) prev.remove();
13740          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
13741          pills.forEach(function(p) { p.style.display = ''; });
13742          if (!pills.length) return;
13743
13744          // Measure after restoring all pills
13745          var containerRight = row.getBoundingClientRect().right;
13746          var hidden = [];
13747          for (var i = pills.length - 1; i >= 1; i--) {
13748            var rect = pills[i].getBoundingClientRect();
13749            if (rect.right > containerRight + 2) {
13750              hidden.unshift(pills[i]);
13751              pills[i].style.display = 'none';
13752            } else {
13753              break;
13754            }
13755          }
13756
13757          if (hidden.length) {
13758            var chip = document.createElement('button');
13759            chip.type = 'button';
13760            chip.className = 'language-pill lang-overflow-chip';
13761            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
13762            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
13763            row.appendChild(chip);
13764          }
13765        });
13766      }
13767
13768      // Run after preview loads (preview panel populates language pills)
13769      var _origLoadPreviewCb = window.__previewLoaded;
13770      document.addEventListener('previewLoaded', collapseLanguagePills);
13771      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
13772      setTimeout(collapseLanguagePills, 400);
13773
13774      // ── Project history & output dir auto-set ──────────────────────────
13775      var wsOutputRoot   = document.getElementById("ws-output-root");
13776      var wsScanCount    = document.getElementById("ws-scan-count");
13777      var wsLastScan     = document.getElementById("ws-last-scan");
13778      var historyBadge   = document.getElementById("path-history-badge");
13779      var historyTimer   = null;
13780
13781      var wsOutputLink = document.getElementById("ws-output-link");
13782      function syncStripOutputRoot() {
13783        var val = outputDirInput ? outputDirInput.value : "";
13784        var display = val || "project/sloc";
13785        if (wsOutputRoot) wsOutputRoot.textContent = display;
13786        if (wsOutputLink) wsOutputLink.dataset.folder = val;
13787      }
13788
13789      function scrollInputToEnd(input) {
13790        if (!input) return;
13791        // Defer so the DOM has the new value before we measure scroll width.
13792        requestAnimationFrame(function () {
13793          input.scrollLeft = input.scrollWidth;
13794          input.selectionStart = input.selectionEnd = input.value.length;
13795        });
13796      }
13797
13798      function autoSetOutputDir(projectPath) {
13799        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
13800        if (GIT_MODE && GIT_OUTPUT_DIR) {
13801          outputDirInput.value = GIT_OUTPUT_DIR;
13802          scrollInputToEnd(outputDirInput);
13803          syncStripOutputRoot();
13804          updateReview();
13805          return;
13806        }
13807        if (!projectPath || !projectPath.trim()) return;
13808        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
13809        outputDirInput.value = cleaned + "/sloc";
13810        scrollInputToEnd(outputDirInput);
13811        syncStripOutputRoot();
13812        updateReview();
13813      }
13814
13815      var wsBranch = document.getElementById("ws-branch");
13816
13817      function fetchProjectHistory(projectPath) {
13818        if (!projectPath || !projectPath.trim()) {
13819          if (wsScanCount) wsScanCount.textContent = "—";
13820          if (wsLastScan)  wsLastScan.textContent  = "—";
13821          if (wsBranch)    wsBranch.textContent    = "—";
13822          if (historyBadge) historyBadge.style.display = "none";
13823          return;
13824        }
13825        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
13826          .then(function (r) { return r.ok ? r.json() : null; })
13827          .then(function (data) {
13828            if (!data) return;
13829            var countStr = data.scan_count > 0
13830              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
13831              : "never";
13832            var tsStr = data.last_scan_timestamp
13833              ? data.last_scan_timestamp.replace(" UTC","")
13834              : "—";
13835            if (wsScanCount) wsScanCount.textContent = countStr;
13836            if (wsLastScan)  wsLastScan.textContent  = tsStr;
13837            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
13838            if (data.scan_count > 0) {
13839              if (historyBadge) {
13840                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
13841                historyBadge.textContent = data.scan_count + " previous scan" +
13842                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
13843                  "Last: " + (data.last_scan_timestamp || "—") +
13844                  " — " + (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.";
13845                historyBadge.className = "path-history-badge found";
13846                historyBadge.style.display = "";
13847              }
13848            } else {
13849              if (historyBadge) historyBadge.style.display = "none";
13850            }
13851          })
13852          .catch(function () {});
13853      }
13854
13855      function onPathChange() {
13856        var val = pathInput ? pathInput.value : "";
13857        // Discard stale upload sizes when the user edits the path manually.
13858        window._lastUploadSizes = null;
13859        updateReportTitleFromPath();
13860        autoSetOutputDir(val);
13861        updateSidebarSummary();
13862        clearTimeout(historyTimer);
13863        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
13864        if (previewTimer) clearTimeout(previewTimer);
13865        previewTimer = setTimeout(loadPreview, 280);
13866        suggestCoverageFile(val);
13867      }
13868
13869      if (pathInput) {
13870        pathInput.addEventListener("input", onPathChange);
13871      }
13872
13873      if (outputDirInput) {
13874        outputDirInput.addEventListener("input", function () {
13875          outputDirInput.dataset.userEdited = "1";
13876          syncStripOutputRoot();
13877          updateReview();
13878        });
13879      }
13880
13881      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
13882        if (!node) return;
13883        node.addEventListener("input", function () {
13884          updateReview();
13885          if (previewTimer) clearTimeout(previewTimer);
13886          previewTimer = setTimeout(loadPreview, 280);
13887        });
13888      });
13889
13890      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
13891        var node = document.getElementById(id);
13892        if (node) node.addEventListener("change", updateReview);
13893      });
13894
13895      if (reportTitleInput) {
13896        reportTitleInput.addEventListener("input", function () {
13897          reportTitleTouched = reportTitleInput.value.trim().length > 0;
13898          updateReportTitleFromPath();
13899          updateReview();
13900        });
13901      }
13902
13903      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
13904      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
13905      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
13906      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
13907
13908      artifactCards.forEach(function (card) {
13909        card.addEventListener("click", function () {
13910          if (card.classList.contains("artifact-locked")) return;
13911          toggleArtifactCard(card);
13912          updateReview();
13913        });
13914      });
13915
13916      if (coverageInput) {
13917        coverageInput.addEventListener("input", function () {
13918          if (coverageInput.value.trim()) setCovStatus("idle");
13919        });
13920      }
13921
13922      if (form && loading && submitButton) {
13923        form.addEventListener("submit", function (e) {
13924          e.preventDefault();
13925          submitButton.disabled = true;
13926          submitButton.textContent = "Scanning...";
13927          startAsyncAnalysis(new FormData(form));
13928        });
13929      }
13930
13931      function openPath(folder) {
13932        if (!folder) return;
13933        fetch('/open-path?path=' + encodeURIComponent(folder))
13934          .then(function (r) { return r.json(); })
13935          .then(function (d) {
13936            if (d && d.server_mode_disabled)
13937              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
13938          })
13939          .catch(function () {});
13940      }
13941
13942      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
13943        btn.addEventListener('click', function () {
13944          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
13945        });
13946      });
13947
13948      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
13949      if (wsOutputLink) {
13950        wsOutputLink.addEventListener('click', function () {
13951          openPath(wsOutputLink.dataset.folder || '');
13952        });
13953      }
13954
13955      loadSavedTheme();
13956      updateMixedPolicyUI();
13957      updatePythonDocstringUI();
13958      applyScanPreset();
13959      updatePresetDescriptions();
13960      applyArtifactPreset();
13961      updateReview();
13962      updateScrollProgress(); // initialise bar to 0% (step 1)
13963      window.addEventListener("scroll", updateScrollProgress, { passive: true });
13964      onPathChange();         // seed output dir, history badge, and preview from initial path
13965      loadPreview();
13966      updateStepNav(1);
13967
13968      // Restore step from URL hash on initial load (e.g., back-forward cache)
13969      (function() {
13970        var hashMatch = location.hash.match(/^#step([1-4])$/);
13971        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
13972      })();
13973
13974      (function randomizeWatermarks() {
13975        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
13976        if (!wms.length) return;
13977        var placed = [];
13978        function tooClose(top, left) {
13979          for (var i = 0; i < placed.length; i++) {
13980            var dt = Math.abs(placed[i][0] - top);
13981            var dl = Math.abs(placed[i][1] - left);
13982            if (dt < 16 && dl < 12) return true;
13983          }
13984          return false;
13985        }
13986        function pick(leftBand) {
13987          for (var attempt = 0; attempt < 50; attempt++) {
13988            var top = Math.random() * 88 + 2;
13989            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13990            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
13991          }
13992          var top = Math.random() * 88 + 2;
13993          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13994          placed.push([top, left]);
13995          return [top, left];
13996        }
13997        var half = Math.floor(wms.length / 2);
13998        wms.forEach(function (img, i) {
13999          var pos = pick(i < half);
14000          var size = Math.floor(Math.random() * 80 + 110);
14001          var rot = (Math.random() * 360).toFixed(1);
14002          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
14003          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;
14004        });
14005      })();
14006
14007      (function spawnCodeParticles() {
14008        var container = document.getElementById('code-particles');
14009        if (!container) return;
14010        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'];
14011        for (var i = 0; i < 38; i++) {
14012          (function(idx) {
14013            var el = document.createElement('span');
14014            el.className = 'code-particle';
14015            el.textContent = snippets[idx % snippets.length];
14016            var left = Math.random() * 94 + 2;
14017            var top = Math.random() * 88 + 6;
14018            var dur = (Math.random() * 10 + 9).toFixed(1);
14019            var delay = (Math.random() * 18).toFixed(1);
14020            var rot = (Math.random() * 26 - 13).toFixed(1);
14021            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14022            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';
14023            container.appendChild(el);
14024          })(i);
14025        }
14026      })();
14027    })();
14028  </script>
14029  <script nonce="{{ csp_nonce }}">
14030    (function () {
14031      var raw = {{ prefill_json|safe }};
14032      if (!raw || typeof raw !== 'object' || !raw.path) return;
14033      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output-dir') scrollInputToEnd(el); } }
14034      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
14035      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
14036      setVal('path-input', raw.path || '');
14037      setVal('include-globs', raw.include_globs || '');
14038      setVal('exclude-globs', raw.exclude_globs || '');
14039      setVal('output-dir', raw.output_dir || '');
14040      setVal('report-title', raw.report_title || '');
14041      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
14042      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
14043      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
14044      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
14045      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
14046      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
14047      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
14048      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
14049      setChecked('generate-html', raw.generate_html !== false);
14050      setChecked('generate-pdf', !!raw.generate_pdf);
14051      // Trigger dynamic UI updates after pre-fill.
14052      setTimeout(function () {
14053        var pathEl = document.getElementById('path-input');
14054        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
14055        var policyEl = document.getElementById('mixed-line-policy');
14056        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
14057      }, 80);
14058    })();
14059  </script>
14060  <script nonce="{{ csp_nonce }}">
14061  (function(){
14062    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'}];
14063    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);});}
14064    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14065    function init(){
14066      var btn=document.getElementById('settings-btn');if(!btn)return;
14067      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14068      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>';
14069      document.body.appendChild(m);
14070      var g=document.getElementById('scheme-grid');
14071      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);});
14072      var cl=document.getElementById('settings-close');
14073      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);
14074      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');});
14075      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14076      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14077    }
14078    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14079  }());
14080  </script>
14081  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
14082    <div class="wb-ftip-arrow"></div>
14083    <span id="wb-ftip-text"></span>
14084  </div>
14085  <script nonce="{{ csp_nonce }}">(function(){
14086    var tip=document.getElementById('wb-ftip');
14087    var txt=document.getElementById('wb-ftip-text');
14088    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
14089    if(!tip||!txt)return;
14090    function pos(el){
14091      var r=el.getBoundingClientRect();
14092      tip.style.display='block';
14093      var tw=tip.offsetWidth;
14094      var lx=r.left+r.width/2-tw/2;
14095      if(lx<8)lx=8;
14096      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
14097      tip.style.left=lx+'px';
14098      tip.style.top=(r.bottom+8)+'px';
14099      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';}
14100    }
14101    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
14102      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
14103      el.addEventListener('mouseleave',function(){tip.style.display='none';});
14104    });
14105  })();
14106  (function(){
14107    function fixArtifactHintSpacing(){
14108      var grid=document.querySelector('.artifact-grid');
14109      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
14110    }
14111    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
14112  }());
14113  (function(){
14114    var dot=document.getElementById('status-dot');
14115    var pingEl=document.getElementById('server-ping-ms');
14116    var tipEl=document.getElementById('server-tip-ping');
14117    var fm=document.getElementById('footer-mode');
14118    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)';}}
14119    function doPing(){
14120      var t0=performance.now();
14121      fetch('/healthz',{cache:'no-store'})
14122        .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);})
14123        .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)';}});
14124    }
14125    doPing();
14126    setInterval(doPing,5000);
14127    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');}
14128  })();
14129  </script>
14130  <footer class="site-footer">
14131    local code analysis - metrics, history and reports
14132    &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>
14133    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14134    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14135    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14136    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14137  </footer>
14138</body>
14139</html>
14140"##,
14141    ext = "html"
14142)]
14143struct IndexTemplate {
14144    version: &'static str,
14145    prefill_json: String,
14146    csp_nonce: String,
14147    git_repo: String,
14148    git_ref: String,
14149    git_label_json: String,
14150    git_output_dir_json: String,
14151    server_mode: bool,
14152}
14153
14154// ── SplashTemplate ────────────────────────────────────────────────────────────
14155
14156#[derive(Template)]
14157#[template(
14158    source = r##"
14159<!doctype html>
14160<html lang="en">
14161<head>
14162  <meta charset="utf-8">
14163  <meta name="viewport" content="width=device-width, initial-scale=1">
14164  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
14165  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14166  <style nonce="{{ csp_nonce }}">
14167    :root {
14168      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14169      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14170      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14171      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14172      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14173    }
14174    body.dark-theme {
14175      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14176      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14177    }
14178    *{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;}
14179    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14180    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14181    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14182    .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;}
14183    @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));}}
14184    .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);}
14185    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14186    .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));}
14187    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14188    .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;}
14189    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14190    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14191    @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; } }
14192    .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;}
14193    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14194    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14195    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14196    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14197    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14198    .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;}
14199    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14200    .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);}
14201    .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;}
14202    .settings-close:hover{color:var(--text);background:var(--surface-2);}
14203    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14204    .settings-modal-body{padding:14px 16px 16px;}
14205    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14206    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14207    .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;}
14208    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14209    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14210    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14211    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14212    .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;}
14213    .tz-select:focus{border-color:var(--oxide);}
14214    .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;}
14215    .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;}
14216    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
14217    .hero{text-align:center;margin:0 auto 18px;}
14218    .hero-logo-wrap{display:inline-block;cursor:default;}
14219    .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;}
14220    .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;}
14221    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
14222    .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;}
14223    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%);}
14224    .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;
14225      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
14226      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
14227      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;}
14228    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
14229    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
14230    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;}
14231    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
14232    .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;}
14233    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
14234    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
14235    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
14236    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
14237    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
14238    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
14239    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
14240    .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;}
14241    .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;}
14242    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14243    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14244    .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);}
14245    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
14246    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
14247    .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);}
14248    .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);}
14249    .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);}
14250    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
14251    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
14252    .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;}
14253    body.dark-theme .action-card-cta{color:var(--oxide);}
14254    .action-card.view .action-card-cta{color:var(--accent-2);}
14255    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
14256    .action-card.compare .action-card-cta{color:#7c3aed;}
14257    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
14258    .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);}
14259    .action-card.git-tools .action-card-cta{color:#15803d;}
14260    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
14261    .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);}
14262    .action-card.trend .action-card-cta{color:#0e7490;}
14263    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
14264    .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);}
14265    .action-card.automation .action-card-cta{color:#b45309;}
14266    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
14267    .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);}
14268    .action-card.test-metrics .action-card-cta{color:#be185d;}
14269    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
14270    .action-card:hover .action-card-cta{gap:12px;}
14271    .action-card.card-split{flex-direction:row;align-items:stretch;}
14272    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
14273    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
14274    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
14275    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
14276    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
14277    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
14278    .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;}
14279    .ac-badge.active{opacity:1;}
14280    .ac-badge.github{border-color:#555;color:#555;}
14281    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
14282    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
14283    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
14284    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
14285    body.dark-theme .ac-right-row{color:var(--muted);}
14286    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
14287    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
14288    .divider{height:1px;background:var(--line);margin:32px 0;}
14289    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
14290    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
14291    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
14292    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
14293      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
14294    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
14295    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
14296    body.dark-theme .info-chip-val{color:var(--oxide);}
14297    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
14298    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
14299      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
14300      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
14301    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
14302      border:6px solid transparent;border-top-color:var(--text);}
14303    .info-chip:hover .info-chip-tip{display:block;}
14304    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
14305    .chip-slide.fading{filter:blur(5px);opacity:0;}
14306    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14307    .site-footer a{color:var(--muted);}
14308    .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;}
14309    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
14310    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
14311    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
14312    .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;}
14313    .lan-badge.local{background:var(--oxide-2);}
14314    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
14315    .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);}
14316    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
14317    .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;}
14318    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
14319    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
14320    .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;}
14321    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
14322    .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;}
14323    .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);}
14324    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
14325    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
14326    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
14327    .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;}
14328    @media (max-height: 1100px) {
14329      .page{padding-top:10px;}
14330      .hero{margin-bottom:10px;}
14331      .hero-logo{width:54px;height:60px;}
14332      .hero-logo-shadow{width:42px;}
14333      .hero-title{font-size:28px;}
14334      .hero-subtitle{font-size:13px;}
14335      .card-sections{gap:16px;margin-bottom:10px;}
14336      .card-section-grid-2,.card-section-grid-3{gap:10px;}
14337      .action-card{padding:8px 15px 8px;}
14338      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
14339      .action-card-icon svg{width:18px;height:18px;}
14340      .action-card-title{font-size:13px;}
14341      .action-card-desc{font-size:11px;margin-bottom:6px;}
14342      .action-card-cta{font-size:11px;}
14343      .ac-right-row{font-size:11px;}
14344      .divider{margin:14px 0;}
14345      .info-strip{gap:7px;margin-bottom:12px;}
14346      .info-chip{padding:7px 10px;}
14347      .info-chip-val{font-size:13px;}
14348      .info-chip-label{font-size:9px;}
14349      .site-footer{padding:8px 24px;font-size:12px;}
14350    }
14351    @media (max-height: 850px) {
14352      .page{padding-top:6px;}
14353      .hero{margin-bottom:6px;}
14354      .hero-logo{width:42px;height:46px;}
14355      .hero-title{font-size:22px;}
14356      .hero-subtitle{font-size:12px;}
14357      .card-sections{gap:10px;}
14358      .action-card-desc{margin-bottom:4px;}
14359      .divider{margin:8px 0;}
14360      .info-strip{margin-bottom:6px;}
14361      .lan-local-hint{margin-top:10px;}
14362    }
14363  </style>
14364</head>
14365<body>
14366  <div class="background-watermarks" aria-hidden="true">
14367    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14368    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14369    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14370    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14371    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14372    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14373    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14374  </div>
14375  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14376  <div class="top-nav">
14377    <div class="top-nav-inner">
14378      <a class="brand" href="/">
14379        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14380        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
14381      </a>
14382      <div class="nav-right">
14383        <a class="nav-pill" href="/">Home</a>
14384        <div class="nav-dropdown">
14385          <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>
14386          <div class="nav-dropdown-menu">
14387            <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>
14388          </div>
14389        </div>
14390        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14391        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14392        <div class="nav-dropdown">
14393          <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>
14394          <div class="nav-dropdown-menu">
14395            <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>
14396          </div>
14397        </div>
14398        <div class="server-status-wrap" id="server-status-wrap">
14399          <div class="nav-pill server-online-pill" id="server-status-pill">
14400            <span class="status-dot" id="status-dot"></span>
14401            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
14402            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14403          </div>
14404          <div class="server-status-tip">
14405            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
14406            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14407          </div>
14408        </div>
14409        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14410          <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>
14411        </button>
14412        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14413          <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>
14414          <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>
14415        </button>
14416      </div>
14417    </div>
14418  </div>
14419
14420  <div class="page">
14421    <div class="hero">
14422      <div class="hero-logo-wrap" id="hero-logo-wrap">
14423        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
14424      </div>
14425      <div class="hero-logo-shadow"></div>
14426      <div class="hero-title-wrap">
14427        <div class="hero-title-aura" aria-hidden="true"></div>
14428        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
14429      </div>
14430      <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>
14431    </div>
14432
14433    <div class="card-sections">
14434
14435      <div>
14436        <div class="card-section-label">Analysis</div>
14437        <div class="card-section-grid-2">
14438          <a class="action-card scan card-split" href="/scan-setup">
14439            <div class="action-card-left">
14440              <div class="action-card-icon">
14441                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
14442              </div>
14443              <div class="action-card-title">Scan Project</div>
14444              <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>
14445              <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>
14446            </div>
14447            <div class="action-card-sep"></div>
14448            <div class="action-card-right">
14449              <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>
14450              <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>
14451              <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>
14452              <div class="ac-right-stat" id="acp-scan-stat"></div>
14453            </div>
14454          </a>
14455          <a class="action-card test-metrics card-split" href="/test-metrics">
14456            <div class="action-card-left">
14457              <div class="action-card-icon">
14458                <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>
14459              </div>
14460              <div class="action-card-title">Test Metrics</div>
14461              <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>
14462              <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>
14463            </div>
14464            <div class="action-card-sep"></div>
14465            <div class="action-card-right">
14466              <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>
14467              <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>
14468              <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>
14469              <div class="ac-right-stat" id="acp-test-stat"></div>
14470            </div>
14471          </a>
14472        </div>
14473      </div>
14474
14475      <div>
14476        <div class="card-section-label">Reports &amp; Insights</div>
14477        <div class="card-section-grid-3">
14478          <a class="action-card view" href="/view-reports">
14479            <div class="action-card-icon">
14480              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
14481            </div>
14482            <div class="action-card-title">View Reports</div>
14483            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
14484            <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>
14485          </a>
14486          <a class="action-card compare" href="/compare-scans">
14487            <div class="action-card-icon">
14488              <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>
14489            </div>
14490            <div class="action-card-title">Compare Scans</div>
14491            <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>
14492            <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>
14493          </a>
14494          <a class="action-card trend" href="/trend-reports">
14495            <div class="action-card-icon">
14496              <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>
14497            </div>
14498            <div class="action-card-title">Trend Report</div>
14499            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
14500            <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>
14501          </a>
14502        </div>
14503      </div>
14504
14505      <div>
14506        <div class="card-section-label">Developer Tools</div>
14507        <div class="card-section-grid-2">
14508          <a class="action-card git-tools card-split" href="/git-browser">
14509            <div class="action-card-left">
14510              <div class="action-card-icon">
14511                <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>
14512              </div>
14513              <div class="action-card-title">Git Browser</div>
14514              <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>
14515              <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>
14516            </div>
14517            <div class="action-card-sep"></div>
14518            <div class="action-card-right">
14519              <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>
14520              <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>
14521              <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>
14522            </div>
14523          </a>
14524          <a class="action-card automation card-split" href="/integrations">
14525            <div class="action-card-left">
14526              <div class="action-card-icon">
14527                <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>
14528              </div>
14529              <div class="action-card-title">Integrations</div>
14530              <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>
14531              <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>
14532            </div>
14533            <div class="action-card-sep"></div>
14534            <div class="action-card-right">
14535              <div class="ac-badges-grid">
14536                <span class="ac-badge github"     id="acp-gh">GitHub</span>
14537                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
14538                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
14539                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
14540              </div>
14541              <div class="ac-right-stat" id="acp-int-stat"></div>
14542            </div>
14543          </a>
14544        </div>
14545      </div>
14546
14547    </div>
14548
14549    {% if server_mode %}
14550    <div class="lan-card server">
14551      <div class="lan-card-header">
14552        <span class="lan-badge">LAN server</span>
14553        Accessible on your network
14554      </div>
14555      {% if let Some(ip) = lan_ip %}
14556      <div class="lan-url-row">
14557        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
14558        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
14559          <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>
14560          Copy URL
14561        </button>
14562      </div>
14563      <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>
14564      {% if has_api_key %}
14565      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
14566      {% endif %}
14567      {% else %}
14568      <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>
14569      {% endif %}
14570    </div>
14571    {% endif %}
14572
14573    <div class="divider"></div>
14574
14575    <div class="info-strip">
14576      <div class="info-chip">
14577        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
14578        <div class="chip-slide">
14579          <div class="info-chip-val">41</div>
14580          <div class="info-chip-label">Languages</div>
14581        </div>
14582      </div>
14583      <div class="info-chip">
14584        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
14585        <div class="chip-slide">
14586          <div class="info-chip-val">100%</div>
14587          <div class="info-chip-label">Self-contained</div>
14588        </div>
14589      </div>
14590      <div class="info-chip">
14591        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
14592        <div class="chip-slide">
14593          <div class="info-chip-val">HTML+PDF</div>
14594          <div class="info-chip-label">Exportable reports</div>
14595        </div>
14596      </div>
14597      <div class="info-chip">
14598        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
14599        <div class="chip-slide">
14600          <div class="info-chip-val">Webhook</div>
14601          <div class="info-chip-label">3 platforms</div>
14602        </div>
14603      </div>
14604      <div class="info-chip">
14605        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
14606        <div class="chip-slide">
14607          <div class="info-chip-val">IEEE</div>
14608          <div class="info-chip-label">1045-1992</div>
14609        </div>
14610      </div>
14611    </div>
14612
14613    {% if lan_ip.is_none() %}
14614    <div class="lan-local-hint">
14615      <strong>Want teammates on the same network to access this?</strong><br>
14616      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
14617    </div>
14618    {% endif %}
14619  </div>
14620
14621  <footer class="site-footer">
14622    local code analysis - metrics, history and reports
14623    &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>
14624    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14625    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14626    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14627    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14628  </footer>
14629
14630  <script nonce="{{ csp_nonce }}">
14631    (function () {
14632      var storageKey = 'oxide-sloc-theme';
14633      var body = document.body;
14634      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
14635      var toggle = document.getElementById('theme-toggle');
14636      if (toggle) toggle.addEventListener('click', function () {
14637        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
14638        body.classList.toggle('dark-theme', next === 'dark');
14639        try { localStorage.setItem(storageKey, next); } catch(e) {}
14640      });
14641      var copyBtn = document.getElementById('lan-copy-btn');
14642      if (copyBtn) copyBtn.addEventListener('click', function() {
14643        var btn = this;
14644        var el = document.getElementById('lan-url-val');
14645        if (!el) return;
14646        var url = el.textContent.trim();
14647        if (navigator.clipboard) {
14648          navigator.clipboard.writeText(url).then(function() {
14649            var orig = btn.innerHTML;
14650            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!';
14651            setTimeout(function() { btn.innerHTML = orig; }, 1800);
14652          });
14653        }
14654      });
14655      (function randomizeWatermarks() {
14656        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14657        if (!wms.length) return;
14658        var placed = [];
14659        function tooClose(top, left) {
14660          for (var i = 0; i < placed.length; i++) {
14661            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
14662            if (dt < 16 && dl < 12) return true;
14663          }
14664          return false;
14665        }
14666        function pick(leftBand) {
14667          for (var attempt = 0; attempt < 50; attempt++) {
14668            var top = Math.random() * 88 + 2;
14669            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14670            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14671          }
14672          var top = Math.random() * 88 + 2;
14673          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
14674          placed.push([top, left]); return [top, left];
14675        }
14676        var half = Math.floor(wms.length / 2);
14677        wms.forEach(function (img, i) {
14678          var pos = pick(i < half);
14679          var size = Math.floor(Math.random() * 100 + 120);
14680          var rot = (Math.random() * 360).toFixed(1);
14681          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
14682          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;
14683        });
14684      })();
14685
14686      (function spawnCodeParticles() {
14687        var container = document.getElementById('code-particles');
14688        if (!container) return;
14689        var snippets = [
14690          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
14691          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
14692          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
14693          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
14694          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
14695        ];
14696        var count = 38;
14697        for (var i = 0; i < count; i++) {
14698          (function(idx) {
14699            var el = document.createElement('span');
14700            el.className = 'code-particle';
14701            var text = snippets[idx % snippets.length];
14702            el.textContent = text;
14703            var left = Math.random() * 94 + 2;
14704            var top = Math.random() * 88 + 6;
14705            var dur = (Math.random() * 10 + 9).toFixed(1);
14706            var delay = (Math.random() * 18).toFixed(1);
14707            var rot = (Math.random() * 26 - 13).toFixed(1);
14708            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14709            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
14710              + '--rot:' + rot + 'deg;--op:' + op + ';'
14711              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
14712            container.appendChild(el);
14713          })(i);
14714        }
14715      })();
14716      (function heroAnimations() {
14717        var sub = document.getElementById('hero-subtitle');
14718        if (sub) {
14719          var full = sub.textContent.trim();
14720          sub.textContent = '';
14721          sub.style.opacity = '1';
14722          var cursor = document.createElement('span');
14723          cursor.className = 'hero-cursor';
14724          sub.appendChild(cursor);
14725          var i = 0;
14726          setTimeout(function() {
14727            var iv = setInterval(function() {
14728              if (i < full.length) {
14729                sub.insertBefore(document.createTextNode(full[i]), cursor);
14730                i++;
14731              } else {
14732                clearInterval(iv);
14733                setTimeout(function() {
14734                  cursor.style.transition = 'opacity 1s ease';
14735                  cursor.style.opacity = '0';
14736                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
14737                }, 2400);
14738              }
14739            }, 11);
14740          }, 374);
14741        }
14742      })();
14743      (function logoBob() {
14744        var logo = document.querySelector('.hero-logo');
14745        var shadow = document.querySelector('.hero-logo-shadow');
14746        if (!logo) return;
14747        var cycleStart = null, cycleDur = 3600;
14748        var peakY = -14, peakScale = 1.07, peakRot = 0;
14749        function newCycle() {
14750          cycleDur = 3000 + Math.random() * 1840;
14751          peakY = -(9 + Math.random() * 13.8);
14752          peakScale = 1.04 + Math.random() * 0.081;
14753          peakRot = (Math.random() * 11.5 - 5.75);
14754        }
14755        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
14756        newCycle();
14757        function frame(ts) {
14758          if (cycleStart === null) cycleStart = ts;
14759          var t = (ts - cycleStart) / cycleDur;
14760          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
14761          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
14762          var y = peakY * phase;
14763          var sc = 1 + (peakScale - 1) * phase;
14764          var rot = peakRot * Math.sin(Math.PI * phase);
14765          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
14766          if (shadow) {
14767            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
14768            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
14769          }
14770          requestAnimationFrame(frame);
14771        }
14772        requestAnimationFrame(frame);
14773      })();
14774      (function mouseEffects() {
14775        var heroTitle = document.getElementById('hero-title');
14776        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
14777        function tick() {
14778          raf = null;
14779          if (heroTitle) {
14780            var r = heroTitle.getBoundingClientRect();
14781            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
14782            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
14783            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
14784          }
14785        }
14786        document.addEventListener('mousemove', function(e) {
14787          mx = e.clientX; my = e.clientY;
14788          if (!raf) raf = requestAnimationFrame(tick);
14789        });
14790        document.addEventListener('mouseleave', function() {
14791          if (heroTitle) {
14792            heroTitle.style.transition = 'transform 0.5s ease';
14793            heroTitle.style.transform = '';
14794            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
14795          }
14796        });
14797        document.querySelectorAll('.action-card').forEach(function(card) {
14798          card.addEventListener('mousemove', function(e) {
14799            var rect = card.getBoundingClientRect();
14800            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
14801            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
14802            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
14803            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
14804          });
14805          card.addEventListener('mouseleave', function() {
14806            card.style.transition = '';
14807            card.style.transform = '';
14808          });
14809        });
14810      })();
14811      (function chipSlideshow() {
14812        var slides = [
14813          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
14814          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
14815          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
14816          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
14817          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
14818        ];
14819        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
14820        var indices = [0,0,0,0,0];
14821        var paused = [false,false,false,false,false];
14822        chips.forEach(function(chip, i) {
14823          chip.addEventListener('mouseenter', function() { paused[i] = true; });
14824          chip.addEventListener('mouseleave', function() { paused[i] = false; });
14825        });
14826        function advance(i) {
14827          if (paused[i]) return;
14828          var chip = chips[i];
14829          var inner = chip.querySelector('.chip-slide');
14830          if (!inner) return;
14831          inner.classList.add('fading');
14832          setTimeout(function() {
14833            indices[i] = (indices[i] + 1) % slides[i].length;
14834            var s = slides[i][indices[i]];
14835            chip.querySelector('.info-chip-val').textContent = s.v;
14836            chip.querySelector('.info-chip-label').textContent = s.l;
14837            inner.classList.remove('fading');
14838          }, 720);
14839        }
14840        setInterval(function() {
14841          chips.forEach(function(chip, i) { advance(i); });
14842        }, 6000);
14843      })();
14844      (function cardLiveData() {
14845        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
14846          var el = document.getElementById('acp-scan-stat');
14847          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
14848        }).catch(function(){});
14849        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
14850          var el = document.getElementById('acp-test-stat');
14851          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
14852        }).catch(function(){});
14853        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
14854          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
14855          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
14856          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
14857          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
14858          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
14859          var stat = document.getElementById('acp-int-stat');
14860          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
14861        }).catch(function(){});
14862        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
14863          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
14864        }).catch(function(){});
14865      })();
14866    })();
14867  </script>
14868  <script nonce="{{ csp_nonce }}">
14869  (function(){
14870    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'}];
14871    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);});}
14872    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14873    function init(){
14874      var btn=document.getElementById('settings-btn');if(!btn)return;
14875      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14876      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>';
14877      document.body.appendChild(m);
14878      var g=document.getElementById('scheme-grid');
14879      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);});
14880      var cl=document.getElementById('settings-close');
14881      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);
14882      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');});
14883      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14884      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14885    }
14886    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14887  }());
14888  </script>
14889  <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>
14890</body>
14891</html>
14892"##,
14893    ext = "html"
14894)]
14895struct SplashTemplate {
14896    csp_nonce: String,
14897    server_mode: bool,
14898    lan_ip: Option<String>,
14899    port: u16,
14900    version: &'static str,
14901    has_api_key: bool,
14902}
14903
14904// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
14905
14906#[derive(Template)]
14907#[template(
14908    source = r##"
14909<!doctype html>
14910<html lang="en">
14911<head>
14912  <meta charset="utf-8">
14913  <meta name="viewport" content="width=device-width, initial-scale=1">
14914  <title>OxideSLOC — Start a Scan</title>
14915  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14916  <style nonce="{{ csp_nonce }}">
14917    :root {
14918      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
14919      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14920      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14921      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14922      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
14923    }
14924    body.dark-theme {
14925      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
14926      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
14927    }
14928    *{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;}
14929    .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);}
14930    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14931    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
14932    .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));}
14933    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
14934    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
14935    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
14936    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14937    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14938    @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; } }
14939    .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;}
14940    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14941    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
14942    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
14943    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
14944    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
14945    .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;}
14946    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
14947    .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);}
14948    .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;}
14949    .settings-close:hover{color:var(--text);background:var(--surface-2);}
14950    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
14951    .settings-modal-body{padding:14px 16px 16px;}
14952    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
14953    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
14954    .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;}
14955    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
14956    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
14957    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
14958    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
14959    .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;}
14960    .tz-select:focus{border-color:var(--oxide);}
14961    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
14962    .page-header{text-align:center;margin-bottom:16px;}
14963    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
14964    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
14965    /* Cards */
14966    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
14967    .option-card-wrap{position:relative;}
14968    .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;}
14969    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
14970    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
14971    .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;}
14972    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
14973    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
14974    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
14975    .card-top-row{display:flex;align-items:center;gap:20px;}
14976    /* Two-column layout inside each card */
14977    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
14978    .card-left{display:flex;align-items:flex-start;min-width:0;}
14979    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
14980    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
14981    .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);}
14982    .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);}
14983    .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);}
14984    .card-text{min-width:0;}
14985    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
14986    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
14987    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
14988    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
14989    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
14990    /* Right CTA column */
14991    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
14992    .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;}
14993    /* Re-scan count badge */
14994    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
14995    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
14996    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
14997    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
14998    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
14999    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
15000    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
15001    body.dark-theme .btn-secondary{color:var(--oxide);}
15002    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
15003    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
15004    /* File input overlay — must be full-width so it aligns with other card-right buttons */
15005    .file-input-wrap{position:relative;width:100%;}
15006    .file-input-wrap .btn{width:100%;}
15007    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
15008    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15009    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15010    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15011    .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;}
15012    @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));}}
15013    /* Recent list (card 3 — full-width section below header) */
15014    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
15015    .recent-list{display:flex;flex-direction:column;gap:8px;}
15016    .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;}
15017    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
15018    .recent-item-info{flex:1;min-width:0;}
15019    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
15020    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
15021    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
15022    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
15023    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15024    .site-footer a{color:var(--muted);}
15025    @media(max-width:680px){
15026      .card-body{grid-template-columns:1fr;}
15027      .card-right{flex-direction:row;flex-wrap:wrap;}
15028      .btn{flex:1;}
15029    }
15030    .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;}
15031    .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;}
15032    .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;}
15033  </style>
15034</head>
15035<body>
15036  <div class="background-watermarks" aria-hidden="true">
15037    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15038    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15039    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15040    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15041    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15042    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15043    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15044  </div>
15045  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15046  <div class="top-nav">
15047    <div class="top-nav-inner">
15048      <a class="brand" href="/">
15049        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15050        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15051      </a>
15052      <div class="nav-right">
15053        <a class="nav-pill" href="/">Home</a>
15054        <div class="nav-dropdown">
15055          <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>
15056          <div class="nav-dropdown-menu">
15057            <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>
15058          </div>
15059        </div>
15060        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15061        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15062        <div class="nav-dropdown">
15063          <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>
15064          <div class="nav-dropdown-menu">
15065            <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>
15066          </div>
15067        </div>
15068        <div class="server-status-wrap" id="server-status-wrap">
15069          <div class="nav-pill server-online-pill" id="server-status-pill">
15070            <span class="status-dot" id="status-dot"></span>
15071            <span id="server-status-label">Server</span>
15072            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15073          </div>
15074          <div class="server-status-tip">
15075            OxideSLOC is running — accessible on your network.
15076            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15077          </div>
15078        </div>
15079        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15080          <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>
15081        </button>
15082        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15083          <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>
15084          <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>
15085        </button>
15086      </div>
15087    </div>
15088  </div>
15089
15090  <div class="page">
15091    <div class="page-header">
15092      <h1>How would you like to scan?</h1>
15093      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
15094    </div>
15095
15096    <div class="option-grid">
15097
15098      <!-- Option 1: New scan -->
15099      <div class="option-card-wrap">
15100        <div class="option-card">
15101        <div class="option-icon new-scan">
15102          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
15103        </div>
15104        <div class="card-body">
15105          <div class="card-left">
15106            <div class="card-text">
15107              <div class="option-title">Start a new scan</div>
15108              <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>
15109              <ul class="feature-list">
15110                <li>Live project scope preview before you run</li>
15111                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
15112                <li>HTML, PDF, and JSON output — your choice</li>
15113              </ul>
15114            </div>
15115          </div>
15116          <div class="card-right">
15117            <a class="btn btn-primary" href="/scan">
15118              Configure &amp; scan
15119              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
15120            </a>
15121            <p class="card-tip">Full 4-step setup · all options</p>
15122          </div>
15123        </div>
15124        </div>
15125      </div>
15126
15127      <!-- Option 2: Load from config file -->
15128      <div class="option-card-wrap">
15129        <div class="option-card">
15130        <div class="option-icon load-config">
15131          <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>
15132        </div>
15133        <div class="card-body">
15134          <div class="card-left">
15135            <div class="card-text">
15136              <div class="option-title">Load a saved config</div>
15137              <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>
15138              <ul class="feature-list">
15139                <li>All 15 settings restored from the file</li>
15140                <li>Fully editable — change path or output dir</li>
15141                <li>Works with any scan-config.json</li>
15142              </ul>
15143            </div>
15144          </div>
15145          <div class="card-right">
15146            <div class="file-input-wrap">
15147              <button class="btn btn-secondary" id="load-config-btn" type="button">
15148                <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>
15149                Choose config file
15150              </button>
15151              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
15152            </div>
15153            <p class="card-tip" id="config-file-name">Exported after every scan</p>
15154          </div>
15155        </div>
15156        </div>
15157      </div>
15158
15159      <!-- Option 3: Re-scan recent project -->
15160      <div class="option-card-wrap">
15161        <div class="option-card" id="recent-card">
15162        <div class="card-top-row">
15163          <div class="option-icon rescan">
15164            <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>
15165          </div>
15166          <div class="card-body">
15167            <div class="card-left">
15168              <div class="card-text">
15169                <div class="option-title">Re-scan a recent project</div>
15170                <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>
15171                <ul class="feature-list">
15172                  <li>All 15+ settings restored from the saved config</li>
15173                  <li>Path and output dir are editable before running</li>
15174                  <li>Only scans with a saved config appear here</li>
15175                </ul>
15176              </div>
15177            </div>
15178            <div class="card-right">
15179              <div class="rescan-count-box">
15180                <div class="rescan-count-num" id="rescan-count-num">—</div>
15181                <div class="rescan-count-label">saved configs</div>
15182              </div>
15183              <a class="btn btn-secondary" href="/view-reports">
15184                <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>
15185                View all runs
15186              </a>
15187              <p class="card-tip">Opens run history</p>
15188            </div>
15189          </div>
15190        </div>
15191        <div class="section-divider"></div>
15192        <div class="recent-list" id="recent-list">
15193          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
15194        </div>
15195        </div>
15196      </div>
15197
15198    </div>
15199  </div>
15200
15201  <footer class="site-footer">
15202    local code analysis - metrics, history and reports
15203    &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>
15204    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15205    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15206    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15207    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
15208  </footer>
15209
15210  <script nonce="{{ csp_nonce }}">
15211    (function () {
15212      var storageKey = 'oxide-sloc-theme';
15213      var body = document.body;
15214      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15215      var toggle = document.getElementById('theme-toggle');
15216      if (toggle) toggle.addEventListener('click', function () {
15217        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15218        body.classList.toggle('dark-theme', next === 'dark');
15219        try { localStorage.setItem(storageKey, next); } catch(e) {}
15220      });
15221
15222      (function randomizeWatermarks() {
15223        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15224        if (!wms.length) return;
15225        var placed = [];
15226        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; }
15227        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]; }
15228        var half = Math.floor(wms.length / 2);
15229        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; });
15230      })();
15231      (function spawnCodeParticles() {
15232        var container = document.getElementById('code-particles');
15233        if (!container) return;
15234        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'];
15235        var count = 38;
15236        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); }
15237      })();
15238      // Recent scans data injected from server
15239      var recentScans = {{ recent_scans_json|safe }};
15240
15241      function configToParams(cfg) {
15242        var p = new URLSearchParams();
15243        p.set('prefilled', '1');
15244        if (cfg.path) p.set('path', cfg.path);
15245        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
15246        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
15247        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
15248        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
15249        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
15250        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
15251        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
15252        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
15253        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
15254        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
15255        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
15256        if (cfg.report_title) p.set('report_title', cfg.report_title);
15257        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
15258        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
15259        return p;
15260      }
15261
15262      // Build recent scan list (capped at 3 visible entries)
15263      var list = document.getElementById('recent-list');
15264      var noNote = document.getElementById('no-recent-note');
15265      var hasAny = false;
15266      var MAX_RECENT = 3;
15267      if (Array.isArray(recentScans)) {
15268        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
15269        var shown = 0;
15270        validEntries.forEach(function (entry) {
15271          if (shown >= MAX_RECENT) return;
15272          shown++;
15273          hasAny = true;
15274          var item = document.createElement('div');
15275          item.className = 'recent-item';
15276          item.title = 'Restore all settings and open wizard';
15277          item.innerHTML =
15278            '<div class="recent-item-info">' +
15279              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
15280              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
15281            '</div>' +
15282            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
15283          item.addEventListener('click', function () {
15284            var params = configToParams(entry.config);
15285            window.location.href = '/scan?' + params.toString();
15286          });
15287          list.appendChild(item);
15288        });
15289        if (validEntries.length > MAX_RECENT) {
15290          var moreEl = document.createElement('div');
15291          moreEl.className = 'recent-more-link';
15292          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
15293          list.appendChild(moreEl);
15294        }
15295      }
15296      if (hasAny && noNote) noNote.style.display = 'none';
15297      // Update count badge
15298      var countEl = document.getElementById('rescan-count-num');
15299      if (countEl) {
15300        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
15301        countEl.textContent = total > 0 ? total : '0';
15302      }
15303
15304      // Config file loader
15305      var fileInput = document.getElementById('config-file-input');
15306      var fileName = document.getElementById('config-file-name');
15307      if (fileInput) {
15308        fileInput.addEventListener('change', function () {
15309          var file = fileInput.files && fileInput.files[0];
15310          if (!file) return;
15311          if (fileName) fileName.textContent = '✓ ' + file.name;
15312          var reader = new FileReader();
15313          reader.onload = function (e) {
15314            try {
15315              var cfg = JSON.parse(e.target.result);
15316              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
15317              var params = configToParams(cfg);
15318              window.location.href = '/scan?' + params.toString();
15319            } catch (err) {
15320              alert('Could not parse config file: ' + err.message);
15321            }
15322          };
15323          reader.readAsText(file);
15324        });
15325      }
15326
15327      function escHtml(s) {
15328        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
15329      }
15330    })();
15331  </script>
15332  <script nonce="{{ csp_nonce }}">
15333  (function(){
15334    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'}];
15335    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);});}
15336    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15337    function init(){
15338      var btn=document.getElementById('settings-btn');if(!btn)return;
15339      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15340      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>';
15341      document.body.appendChild(m);
15342      var g=document.getElementById('scheme-grid');
15343      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);});
15344      var cl=document.getElementById('settings-close');
15345      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);
15346      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');});
15347      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15348      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15349    }
15350    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15351  }());
15352  </script>
15353  <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>
15354</body>
15355</html>
15356"##,
15357    ext = "html"
15358)]
15359struct ScanSetupTemplate {
15360    version: &'static str,
15361    recent_scans_json: String,
15362    csp_nonce: String,
15363}
15364
15365#[derive(Template)]
15366#[template(
15367    source = r##"
15368<!doctype html>
15369<html lang="en">
15370<head>
15371  <meta charset="utf-8">
15372  <meta name="viewport" content="width=device-width, initial-scale=1">
15373  <title>OxideSLOC | {{ report_title }} | Report</title>
15374  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15375  <style nonce="{{ csp_nonce }}">
15376    :root {
15377      --radius: 18px;
15378      --bg: #f5efe8;
15379      --surface: rgba(255,255,255,0.82);
15380      --surface-2: #fbf7f2;
15381      --surface-3: #efe6dc;
15382      --line: #e6d0bf;
15383      --line-strong: #dcb89f;
15384      --text: #43342d;
15385      --muted: #7b675b;
15386      --muted-2: #a08777;
15387      --nav: #b85d33;
15388      --nav-2: #7a371b;
15389      --accent: #6f9bff;
15390      --accent-2: #4a78ee;
15391      --oxide: #d37a4c;
15392      --oxide-2: #b35428;
15393      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
15394      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
15395      --success-bg: #e8f5ed;
15396      --success-text: #1a8f47;
15397      --info-bg: #eef3ff;
15398      --info-text: #4467d8;
15399    }
15400
15401    body.dark-theme {
15402      --bg: #1b1511;
15403      --surface: #261c17;
15404      --surface-2: #2d221d;
15405      --surface-3: #372922;
15406      --line: #524238;
15407      --line-strong: #6c5649;
15408      --text: #f5ece6;
15409      --muted: #c7b7aa;
15410      --muted-2: #aa9485;
15411      --nav: #b85d33;
15412      --nav-2: #7a371b;
15413      --accent: #6f9bff;
15414      --accent-2: #4a78ee;
15415      --oxide: #d37a4c;
15416      --oxide-2: #b35428;
15417      --shadow: 0 18px 42px rgba(0,0,0,0.28);
15418      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
15419      --success-bg: #163927;
15420      --success-text: #8fe2a8;
15421      --info-bg: #1c2847;
15422      --info-text: #a9c1ff;
15423    }
15424
15425    * { box-sizing: border-box; }
15426    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); }
15427    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15428    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15429    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15430    .top-nav, .page { position: relative; z-index: 2; }
15431    .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); }
15432    .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; }
15433    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15434    .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)); }
15435    .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; }
15436    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15437    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15438    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15439    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15440    .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; }
15441    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15442    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15443    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15444    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15445    @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; } }
15446    .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; }
15447    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15448    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15449    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15450    .theme-toggle .icon-sun { display:none; }
15451    body.dark-theme .theme-toggle .icon-sun { display:block; }
15452    body.dark-theme .theme-toggle .icon-moon { display:none; }
15453    .settings-modal{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
15454    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15455    .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
15456    .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
15457    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15458    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15459    .settings-modal-body{padding:14px 16px 16px;}
15460    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15461    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15462    .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
15463    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15464    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15465    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15466    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15467    .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
15468    .tz-select:focus{border-color:var(--oxide);}
15469    .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; }
15470    .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;}
15471    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
15472    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
15473    .hero, .panel { padding: 22px; }
15474    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
15475    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15476    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
15477    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
15478    .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; }
15479    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
15480    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
15481    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
15482    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
15483    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
15484    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
15485    .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; }
15486    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
15487    .delta-card-val { font-size:16px; font-weight:800; }
15488    .delta-card-val.pos { color:#1e7e34; }
15489    .delta-card-val.neg { color:var(--neg); }
15490    .delta-card-val.mod { color:#b35428; }
15491    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
15492    .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; }
15493    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15494    .delta-card-inline:hover .delta-card-tip { opacity:1; }
15495    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
15496    .compare-ts { font-size:13px; color:var(--muted); }
15497    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
15498    .compare-arrow { color: var(--muted); }
15499    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
15500    .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; }
15501    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
15502    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
15503    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
15504    .run-mgmt-card { flex:1; min-width:220px; padding:12px 16px; border-radius:14px; border:1px solid var(--line); background:var(--surface-2); display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center; }
15505    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
15506    .run-mgmt-card .action-buttons { justify-content:center; }
15507    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
15508    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
15509    .button, .copy-button {
15510      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;
15511    }
15512    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
15513    @keyframes spin { to { transform: rotate(360deg); } }
15514    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
15515    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
15516    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
15517    .path-item strong { display: block; margin-bottom: 6px; }
15518    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
15519    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
15520    .path-subitem { flex: 1; }
15521    .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); }
15522    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); }
15523    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
15524    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
15525    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
15526    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
15527    th { color: var(--muted); font-weight: 700; }
15528    tr:last-child td { border-bottom: none; }
15529    #subm-tbl col:nth-child(1){width:15%;}
15530    #subm-tbl col:nth-child(2){width:31%;}
15531    #subm-tbl col:nth-child(3){width:9%;}
15532    #subm-tbl col:nth-child(4){width:9%;}
15533    #subm-tbl col:nth-child(5){width:9%;}
15534    #subm-tbl col:nth-child(6){width:9%;}
15535    #subm-tbl col:nth-child(7){width:9%;}
15536    #subm-tbl col:nth-child(8){width:9%;}
15537    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
15538    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
15539    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
15540    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
15541    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
15542    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
15543    .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; }
15544    .soft-chip.success { gap:5px; padding:0 10px 0 8px; min-height:22px; background:rgba(26,143,71,0.06); color:var(--muted); border:1px solid rgba(26,143,71,0.18); font-size:11px; font-weight:600; letter-spacing:0.03em; }
15545    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
15546    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
15547    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
15548    .muted { color: var(--muted); }
15549    /* Run-ID chip row (mirrors HTML report) */
15550    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
15551    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
15552    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
15553    .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; }
15554    .run-id-chip[data-copy] { cursor:pointer; }
15555    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
15556    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
15557    .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; }
15558    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
15559    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15560    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
15561    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
15562    a.commit-link-value { color:inherit; text-decoration:none; }
15563    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
15564    .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; }
15565    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15566    .run-id-chip:hover .chip-tooltip { opacity:1; }
15567    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
15568    .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; }
15569    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
15570    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
15571    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
15572    /* Meta chips row */
15573    .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); }
15574    .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; }
15575    .meta-chip:first-child { padding-left:0; }
15576    .meta-chip:last-child { border-right:none; }
15577    .meta-chip b { color:var(--text); font-weight:700; }
15578    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15579    .site-footer a{color:var(--muted);}
15580    .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; }
15581    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
15582    .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; }
15583    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
15584    /* Stat chips (matches HTML report) */
15585    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
15586    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
15587    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15588    .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; }
15589    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
15590    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
15591    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
15592    .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; }
15593    .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; }
15594    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
15595    .stat-chip:hover .stat-chip-tip { opacity:1; }
15596    /* Submodule panel */
15597    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
15598    /* Metrics tables stack */
15599    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
15600    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
15601    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
15602    .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)); }
15603    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
15604    /* Metrics table */
15605    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
15606    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
15607    .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; }
15608    .metrics-table thead th:not(:first-child) { text-align: right; }
15609    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
15610    .metrics-table tbody tr:last-child td { border-bottom: none; }
15611    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
15612    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
15613    .metrics-table tbody tr:hover td { background: var(--surface-2); }
15614    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
15615    .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; }
15616    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
15617    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
15618    .mt-val-pos { color: var(--pos); font-weight: 700; }
15619    .mt-val-neg { color: var(--neg); font-weight: 700; }
15620    .mt-val-zero { color: var(--muted); }
15621    .mt-val-mod { color: var(--oxide-2); }
15622    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
15623    @media (max-width: 1180px) {
15624      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
15625      .nav-project-slot, .nav-status { justify-content:flex-start; }
15626      .hero-top { flex-direction: column; }
15627      .run-mgmt-strip { flex-direction: column; }
15628    }
15629    .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;}
15630    @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));}}
15631    .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;}
15632    /* ── Result-page chart controls ─────────────────────────────────────────── */
15633    .r-chart-section{margin-bottom:24px;}
15634    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
15635    .section-pair > .panel{flex-shrink:0;}
15636    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
15637    .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;}
15638    .r-chart-select:focus{border-color:var(--accent);}
15639    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
15640    .r-chart-container svg{display:block;width:100%;height:auto;}
15641    .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;}
15642    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
15643    .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;}
15644    .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);}
15645    .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;}
15646    .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;}
15647    .r-chart-modal-close:hover{opacity:.7;}
15648    body.dark-theme .r-chart-modal{background:var(--surface);}
15649    .r-chart-container .rchit,.r-expand-modal-chart .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
15650    .r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover{opacity:.75;filter:brightness(1.14);}
15651    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
15652    .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;}
15653    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
15654    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
15655    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
15656    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
15657    #r-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:10px;padding:8px 13px;font-size:12px;line-height:1.5;pointer-events:none;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
15658    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
15659    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
15660    .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;}
15661    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
15662    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
15663    .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface);box-shadow:var(--shadow);display:flex;flex-direction:column;}
15664    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
15665    .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%;}
15666    .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%;}
15667    body.has-report-banner .top-nav{top:27px;}
15668    body.has-report-banner{padding-bottom:27px;}
15669  </style>
15670</head>
15671<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
15672  <div class="background-watermarks" aria-hidden="true">
15673    <img src="/images/logo/logo-text.png" alt="" />
15674    <img src="/images/logo/logo-text.png" alt="" />
15675    <img src="/images/logo/logo-text.png" alt="" />
15676    <img src="/images/logo/logo-text.png" alt="" />
15677    <img src="/images/logo/logo-text.png" alt="" />
15678    <img src="/images/logo/logo-text.png" alt="" />
15679    <img src="/images/logo/logo-text.png" alt="" />
15680    <img src="/images/logo/logo-text.png" alt="" />
15681    <img src="/images/logo/logo-text.png" alt="" />
15682    <img src="/images/logo/logo-text.png" alt="" />
15683    <img src="/images/logo/logo-text.png" alt="" />
15684    <img src="/images/logo/logo-text.png" alt="" />
15685    <img src="/images/logo/logo-text.png" alt="" />
15686    <img src="/images/logo/logo-text.png" alt="" />
15687  </div>
15688  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15689  {% if let Some(banner) = report_header_footer %}
15690  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
15691  {% endif %}
15692  <div class="top-nav">
15693    <div class="top-nav-inner">
15694      <a class="brand" href="/">
15695        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15696        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
15697      </a>
15698      <div class="nav-project-slot">
15699        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
15700      </div>
15701      <div class="nav-status">
15702        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
15703        <div class="nav-dropdown">
15704          <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>
15705          <div class="nav-dropdown-menu">
15706            <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>
15707          </div>
15708        </div>
15709        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
15710        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15711        <div class="nav-dropdown">
15712          <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>
15713          <div class="nav-dropdown-menu">
15714            <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>
15715          </div>
15716        </div>
15717        <div class="server-status-wrap" id="server-status-wrap">
15718          <div class="nav-pill server-online-pill" id="server-status-pill">
15719            <span class="status-dot" id="status-dot"></span>
15720            <span id="server-status-label">Server</span>
15721            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15722          </div>
15723          <div class="server-status-tip">
15724            OxideSLOC is running — accessible on your network.
15725            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
15726          </div>
15727        </div>
15728        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15729          <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>
15730        </button>
15731        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
15732          <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>
15733          <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>
15734        </button>
15735      </div>
15736    </div>
15737  </div>
15738
15739  <div class="page">
15740    <section class="hero">
15741      <div class="hero-top">
15742        <div>
15743          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
15744            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
15745            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
15746            <div class="soft-chip success" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
15747          </div>
15748        </div>
15749        <div class="hero-quick-actions">
15750          {% if server_mode %}
15751          <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>
15752          {% else %}
15753          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
15754          {% endif %}
15755          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
15756          {% if !server_mode %}
15757          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
15758          {% endif %}
15759        </div>
15760      </div>
15761
15762      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
15763      <div class="run-id-row">
15764        <span class="run-id-chip" data-copy="{{ run_id }}">
15765          <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>
15766          <span class="run-id-chip-value">{{ run_id }}</span>
15767          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
15768        </span>
15769        {% match git_commit_long %}
15770          {% when Some with (long_sha) %}
15771          {% match git_commit_url %}
15772            {% when Some with (commit_url) %}
15773            <span class="run-id-chip" data-copy="{{ long_sha }}">
15774              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
15775              <a href="{{ commit_url }}" target="_blank" rel="noopener" class="run-id-chip-value commit-link-value" onclick="event.stopPropagation()">{{ long_sha }}</a>
15776              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
15777            </span>
15778            {% when None %}
15779            <span class="run-id-chip" data-copy="{{ long_sha }}">
15780              <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>
15781              <span class="run-id-chip-value">{{ long_sha }}</span>
15782              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
15783            </span>
15784          {% endmatch %}
15785          {% when None %}
15786          <span class="run-id-chip muted-chip">
15787            <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>
15788            <span class="run-id-chip-value">Not detected</span>
15789            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
15790          </span>
15791        {% endmatch %}
15792        {% match git_branch %}
15793          {% when Some with (branch) %}
15794          <span class="run-id-chip">
15795            <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>
15796            <span class="run-id-chip-value">{{ branch }}</span>
15797            <span class="chip-tooltip">Git branch active at scan time</span>
15798          </span>
15799          {% when None %}
15800          <span class="run-id-chip muted-chip">
15801            <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>
15802            <span class="run-id-chip-value">Not detected</span>
15803            <span class="chip-tooltip">No Git branch was found for this scan</span>
15804          </span>
15805        {% endmatch %}
15806        {% match git_author %}
15807          {% when Some with (author) %}
15808          <span class="run-id-chip" data-author="{{ author }}">
15809            <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>
15810            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
15811            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
15812          </span>
15813          {% when None %}
15814          <span class="run-id-chip muted-chip">
15815            <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>
15816            <span class="run-id-chip-value">Not detected</span>
15817            <span class="chip-tooltip">No commit author was found for this scan</span>
15818          </span>
15819        {% endmatch %}
15820      </div>
15821
15822      <!-- Scan metadata row -->
15823      <div class="meta">
15824        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
15825        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
15826        <span class="meta-chip">Generated <b>{{ generated_display }}</b></span>
15827        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
15828        <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
15829        <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
15830      </div>
15831
15832      <!-- 12 summary stat chips -->
15833      <div class="summary-strip">
15834        <div class="stat-chip" data-raw="{{ physical_lines }}">
15835          <div class="stat-chip-label">Physical lines</div>
15836          <div class="stat-chip-val">{{ physical_lines }}</div>
15837          <div class="stat-chip-exact"></div>
15838          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
15839        </div>
15840        <div class="stat-chip" data-raw="{{ code_lines }}">
15841          <div class="stat-chip-label">Code</div>
15842          <div class="stat-chip-val">{{ code_lines }}</div>
15843          <div class="stat-chip-exact"></div>
15844          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
15845        </div>
15846        <div class="stat-chip" data-raw="{{ comment_lines }}">
15847          <div class="stat-chip-label">Comments</div>
15848          <div class="stat-chip-val">{{ comment_lines }}</div>
15849          <div class="stat-chip-exact"></div>
15850          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
15851        </div>
15852        <div class="stat-chip" data-raw="{{ blank_lines }}">
15853          <div class="stat-chip-label">Blank</div>
15854          <div class="stat-chip-val">{{ blank_lines }}</div>
15855          <div class="stat-chip-exact"></div>
15856          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
15857        </div>
15858        <div class="stat-chip" data-raw="{{ mixed_lines }}">
15859          <div class="stat-chip-label">Mixed separate</div>
15860          <div class="stat-chip-val">{{ mixed_lines }}</div>
15861          <div class="stat-chip-exact"></div>
15862          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
15863        </div>
15864        <div class="stat-chip" data-raw="{{ functions }}">
15865          <div class="stat-chip-label">Functions</div>
15866          <div class="stat-chip-val">{{ functions }}</div>
15867          <div class="stat-chip-exact"></div>
15868          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
15869        </div>
15870        <div class="stat-chip" data-raw="{{ classes }}">
15871          <div class="stat-chip-label">Classes / Types</div>
15872          <div class="stat-chip-val">{{ classes }}</div>
15873          <div class="stat-chip-exact"></div>
15874          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
15875        </div>
15876        <div class="stat-chip" data-raw="{{ variables }}">
15877          <div class="stat-chip-label">Variables</div>
15878          <div class="stat-chip-val">{{ variables }}</div>
15879          <div class="stat-chip-exact"></div>
15880          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
15881        </div>
15882        <div class="stat-chip" data-raw="{{ imports }}">
15883          <div class="stat-chip-label">Imports</div>
15884          <div class="stat-chip-val">{{ imports }}</div>
15885          <div class="stat-chip-exact"></div>
15886          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
15887        </div>
15888        <div class="stat-chip" data-raw="{{ test_count }}">
15889          <div class="stat-chip-label">Tests</div>
15890          <div class="stat-chip-val">{{ test_count }}</div>
15891          <div class="stat-chip-exact"></div>
15892          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
15893        </div>
15894        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
15895          <div class="stat-chip-label">Code density</div>
15896          <div class="stat-chip-val stat-chip-density-val">—</div>
15897          <div class="stat-chip-exact"></div>
15898          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
15899        </div>
15900        <div class="stat-chip" data-raw="{{ files_analyzed }}">
15901          <div class="stat-chip-label">Files analyzed</div>
15902          <div class="stat-chip-val">{{ files_analyzed }}</div>
15903          <div class="stat-chip-exact"></div>
15904          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
15905        </div>
15906      </div>
15907
15908      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
15909      <div class="compare-banner">
15910        <div class="compare-banner-body">
15911          <div class="compare-banner-meta">
15912            <span class="compare-label">Previous scan</span>
15913            <span class="compare-ts">{{ prev_ts }}</span>
15914            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
15915            {% if let Some(prev_code) = prev_run_code_lines %}
15916            <div class="compare-banner-stats" style="margin-top:4px;">
15917              <span>Code before: <strong>{{ prev_code }}</strong></span>
15918              <span class="compare-arrow">→</span>
15919              <span>Code now: <strong>{{ code_lines }}</strong></span>
15920              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
15921              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
15922            </div>
15923            {% endif %}
15924          </div>
15925          {% if delta_lines_added.is_some() %}
15926          <div class="delta-cards-inline">
15927            <div class="delta-card-inline">
15928              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
15929              <div class="delta-card-lbl">lines added</div>
15930              <div class="delta-card-tip">Code lines added since the previous scan</div>
15931            </div>
15932            <div class="delta-card-inline">
15933              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
15934              <div class="delta-card-lbl">lines removed</div>
15935              <div class="delta-card-tip">Code lines removed since the previous scan</div>
15936            </div>
15937            <div class="delta-card-inline">
15938              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
15939              <div class="delta-card-lbl">unmodified lines</div>
15940              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
15941            </div>
15942            <div class="delta-card-inline">
15943              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
15944              <div class="delta-card-lbl">files modified</div>
15945              <div class="delta-card-tip">Files with at least one line changed</div>
15946            </div>
15947            <div class="delta-card-inline">
15948              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
15949              <div class="delta-card-lbl">files added</div>
15950              <div class="delta-card-tip">New files added since the previous scan</div>
15951            </div>
15952            <div class="delta-card-inline">
15953              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
15954              <div class="delta-card-lbl">files removed</div>
15955              <div class="delta-card-tip">Files deleted since the previous scan</div>
15956            </div>
15957            <div class="delta-card-inline">
15958              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
15959              <div class="delta-card-lbl">files unchanged</div>
15960              <div class="delta-card-tip">Files with no changes since the previous scan</div>
15961            </div>
15962          </div>
15963          {% else %}
15964          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
15965            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
15966          </p>
15967          {% endif %}
15968          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
15969        </div>
15970      </div>
15971      {% endif %}{% endif %}
15972
15973      <div class="action-grid">
15974        <div class="action-card">
15975          <h3>HTML report</h3>
15976          <div class="action-buttons">
15977            {% match html_url %}
15978              {% when Some with (url) %}
15979                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
15980              {% when None %}{% endmatch %}
15981            {% match html_download_url %}
15982              {% when Some with (url) %}
15983                <a class="button secondary" href="{{ url }}">Download HTML</a>
15984              {% when None %}{% endmatch %}
15985            {% match html_path %}
15986              {% when Some with (_path) %}{% when None %}{% endmatch %}
15987            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
15988          </div>
15989        </div>
15990        <div class="action-card">
15991          <h3>PDF report</h3>
15992          <div class="action-buttons">
15993            {% match pdf_url %}
15994              {% when Some with (url) %}
15995                {% if pdf_generating %}
15996                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
15997                    <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>
15998                    Generating PDF…
15999                  </button>
16000                {% else %}
16001                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
16002                {% endif %}
16003              {% when None %}
16004                {% match html_url %}
16005                  {% when Some with (hurl) %}
16006                    <a class="button" href="{{ hurl }}?autoprint=1" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
16007                    <p class="action-empty-note" style="margin-top:6px;font-size:11px;">
16008                      No PDF renderer found on the server. Opens the HTML report in your browser
16009                      with the print dialog ready — choose <strong>Save as PDF</strong>.
16010                    </p>
16011                  {% when None %}
16012                    <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;">
16013                      PDF and HTML reports were not generated for this run. Re-run with HTML or PDF output enabled.
16014                    </p>
16015                {% endmatch %}
16016            {% endmatch %}
16017            {% match pdf_download_url %}
16018              {% when Some with (url) %}
16019                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
16020              {% when None %}{% endmatch %}
16021            {% match pdf_url %}
16022              {% when Some with (_) %}
16023                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
16024              {% when None %}{% endmatch %}
16025          </div>
16026        </div>
16027        <div class="action-card">
16028          <h3>JSON result</h3>
16029          <div class="action-buttons">
16030            {% match json_url %}
16031              {% when Some with (url) %}
16032                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
16033              {% when None %}{% endmatch %}
16034            {% match json_download_url %}
16035              {% when Some with (url) %}
16036                <a class="button secondary" href="{{ url }}">Download JSON</a>
16037              {% when None %}{% endmatch %}
16038            {% match json_path %}
16039              {% when Some with (_path) %}
16040                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
16041              {% when None %}
16042                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
16043              {% endmatch %}
16044          </div>
16045        </div>
16046        <div class="action-card">
16047          <h3>Scan config</h3>
16048          <div class="action-buttons">
16049            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
16050            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
16051            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
16052          </div>
16053        </div>
16054        {% if confluence_configured %}
16055        <div class="action-card" id="confluenceCard">
16056          <h3>Confluence</h3>
16057          <div class="action-buttons">
16058            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
16059            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
16060          </div>
16061          <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>
16062        </div>
16063        {% endif %}
16064      </div>
16065      <div class="run-mgmt-strip">
16066        <div class="run-mgmt-card">
16067          <h3>Download bundle</h3>
16068          <div class="action-buttons">
16069            <button class="button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
16070          </div>
16071          <p class="action-empty-note" style="margin-top:6px;">Downloads a .tar.gz archive containing every artifact for this run (HTML, PDF, JSON, CSV, scan config).</p>
16072        </div>
16073        <div class="run-mgmt-card" id="delete-run-card">
16074          <h3>Delete run</h3>
16075          <div class="action-buttons">
16076            <button class="button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete this run</button>
16077          </div>
16078          <p class="action-empty-note" style="margin-top:6px;">Permanently removes all artifacts for this run from disk. This action cannot be undone.</p>
16079        </div>
16080      </div>
16081      {% if confluence_configured %}
16082      <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;">
16083        <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);">
16084          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
16085          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
16086          <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;">
16087          <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>
16088          <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;">
16089          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16090          <div style="display:flex;gap:10px;justify-content:flex-end;">
16091            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
16092            <button class="button" id="confSubmitBtn" type="button">Post</button>
16093          </div>
16094        </div>
16095      </div>
16096      {% endif %}
16097      <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;">
16098        <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);">
16099          <div style="font-size:16px;font-weight:800;margin-bottom:10px;color:#b23030;">Delete run — irreversible</div>
16100          <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>
16101          <div id="delete-run-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
16102          <div style="display:flex;gap:10px;justify-content:flex-end;">
16103            <button class="button secondary" id="delete-run-cancel" type="button">Cancel</button>
16104            <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;">Yes, delete permanently</button>
16105          </div>
16106        </div>
16107      </div>
16108      {% if !submodule_rows.is_empty() %}
16109      <div class="submodule-panel">
16110        <div class="toolbar-row">
16111          <div>
16112            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
16113            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
16114          </div>
16115          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
16116        </div>
16117        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
16118        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
16119          <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>
16120          <thead>
16121            <tr>
16122              <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>
16123              <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>
16124              <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>
16125              <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>
16126              <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>
16127              <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>
16128              <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>
16129              <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>
16130            </tr>
16131          </thead>
16132          <tbody>
16133            {% for row in submodule_rows %}
16134            <tr>
16135              <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>
16136              <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>
16137              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
16138              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
16139              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
16140              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
16141              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
16142              <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>
16143            </tr>
16144            {% endfor %}
16145          </tbody>
16146        </table>
16147        </div>
16148      </div>
16149      {% endif %}
16150
16151      <div class="metrics-tables-stack">
16152
16153        <div class="metrics-table-wrap">
16154          <div class="metrics-table-title">Files</div>
16155          <table class="metrics-table">
16156            <thead>
16157              <tr>
16158                <th>Metric</th>
16159                <th>This Run</th>
16160                <th>Previous</th>
16161                <th>Change</th>
16162              </tr>
16163            </thead>
16164            <tbody>
16165              <tr>
16166                <td>Files analyzed</td>
16167                <td class="mt-val-large">{{ files_analyzed }}</td>
16168                <td>{{ prev_fa_str }}</td>
16169                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
16170              </tr>
16171              <tr>
16172                <td>Files skipped</td>
16173                <td>{{ files_skipped }}</td>
16174                <td>{{ prev_fs_str }}</td>
16175                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
16176              </tr>
16177              <tr>
16178                <td>Files modified</td>
16179                <td class="mt-val-na">—</td>
16180                <td class="mt-val-na">—</td>
16181                <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>
16182              </tr>
16183              <tr>
16184                <td>Files unchanged</td>
16185                <td class="mt-val-na">—</td>
16186                <td class="mt-val-na">—</td>
16187                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
16188              </tr>
16189            </tbody>
16190          </table>
16191        </div>
16192
16193        <div class="metrics-table-wrap">
16194          <div class="metrics-table-title">Line Counts</div>
16195          <table class="metrics-table">
16196            <thead>
16197              <tr>
16198                <th>Metric</th>
16199                <th>This Run</th>
16200                <th>Previous</th>
16201                <th>Change</th>
16202              </tr>
16203            </thead>
16204            <tbody>
16205              <tr>
16206                <td>Physical lines</td>
16207                <td class="mt-val-large">{{ physical_lines }}</td>
16208                <td>{{ prev_pl_str }}</td>
16209                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
16210              </tr>
16211              <tr>
16212                <td>Code lines</td>
16213                <td class="mt-val-large">{{ code_lines }}</td>
16214                <td>{{ prev_cl_str }}</td>
16215                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
16216              </tr>
16217              <tr>
16218                <td>Comment lines</td>
16219                <td>{{ comment_lines }}</td>
16220                <td>{{ prev_cml_str }}</td>
16221                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
16222              </tr>
16223              <tr>
16224                <td>Blank lines</td>
16225                <td>{{ blank_lines }}</td>
16226                <td>{{ prev_bl_str }}</td>
16227                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
16228              </tr>
16229              <tr>
16230                <td>Mixed (separate)</td>
16231                <td>{{ mixed_lines }}</td>
16232                <td class="mt-val-na">—</td>
16233                <td class="mt-val-na">—</td>
16234              </tr>
16235            </tbody>
16236          </table>
16237        </div>
16238
16239        <div class="metrics-tables-lower">
16240          <div class="metrics-table-wrap">
16241            <div class="metrics-table-title">Code Structure</div>
16242            <table class="metrics-table">
16243              <thead>
16244                <tr>
16245                  <th>Metric</th>
16246                  <th>This Run</th>
16247                </tr>
16248              </thead>
16249              <tbody>
16250                <tr>
16251                  <td>Functions</td>
16252                  <td>{{ functions }}</td>
16253                </tr>
16254                <tr>
16255                  <td>Classes / Types</td>
16256                  <td>{{ classes }}</td>
16257                </tr>
16258                <tr>
16259                  <td>Variables</td>
16260                  <td>{{ variables }}</td>
16261                </tr>
16262                <tr>
16263                  <td>Imports</td>
16264                  <td>{{ imports }}</td>
16265                </tr>
16266              </tbody>
16267            </table>
16268          </div>
16269
16270          <div class="metrics-table-wrap">
16271            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
16272            <table class="metrics-table">
16273              <thead>
16274                <tr>
16275                  <th>Metric</th>
16276                  <th>Change</th>
16277                </tr>
16278              </thead>
16279              <tbody>
16280                <tr>
16281                  <td>Lines added</td>
16282                  <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>
16283                </tr>
16284                <tr>
16285                  <td>Lines removed</td>
16286                  <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>
16287                </tr>
16288                <tr>
16289                  <td>Lines modified (net)</td>
16290                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
16291                </tr>
16292                <tr>
16293                  <td>Lines unmodified</td>
16294                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
16295                </tr>
16296              </tbody>
16297            </table>
16298          </div>
16299        </div>
16300
16301      </div>
16302
16303      <div class="path-list">
16304        <div class="path-item">
16305          <div class="path-item-label">Project path</div>
16306          <code>{{ project_path }}</code>
16307        </div>
16308        <div class="path-item">
16309          <div class="path-item-label">Git branch</div>
16310          {% if let Some(branch) = git_branch %}
16311          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
16312          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
16313          {% else %}
16314          <code style="color:var(--muted)">—</code>
16315          {% endif %}
16316        </div>
16317        <div class="path-item">
16318          <div class="path-item-label">Output folder</div>
16319          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
16320        </div>
16321        <div class="path-item">
16322          <div class="path-item-label">Run ID</div>
16323          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
16324            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
16325            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
16326          </div>
16327        </div>
16328      </div>
16329    </section>
16330
16331    <div class="section-pair">
16332    <section class="panel">
16333        <div class="toolbar-row">
16334          <div>
16335            <h2>Language breakdown</h2>
16336            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
16337          </div>
16338        </div>
16339        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
16340    </section>
16341
16342    <section class="panel r-chart-section">
16343      <div class="toolbar-row" style="margin-bottom:16px;">
16344        <div>
16345          <h2>Visualizations</h2>
16346          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
16347        </div>
16348      </div>
16349
16350      <div class="r-viz-grid">
16351        <div class="r-viz-card">
16352          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16353            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
16354            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16355          </div>
16356          <div class="r-chart-tab-bar">
16357            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
16358            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
16359          </div>
16360          <div class="r-chart-container" id="r-composition-chart"></div>
16361        </div>
16362        <div class="r-viz-card">
16363          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16364            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
16365            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16366          </div>
16367          <div class="r-chart-container" id="r-scatter-chart"></div>
16368        </div>
16369        {% if has_semantic_data %}
16370        <div class="r-viz-card">
16371          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
16372            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
16373            <select class="r-chart-select" id="r-semantic-metric">
16374              <option value="functions">Functions</option>
16375              <option value="classes">Classes</option>
16376              <option value="variables">Variables</option>
16377              <option value="imports">Imports</option>
16378            </select>
16379            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16380          </div>
16381          <div class="r-chart-container" id="r-semantic-chart"></div>
16382        </div>
16383        {% endif %}
16384        <div class="r-viz-card">
16385          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16386            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
16387            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16388          </div>
16389          <div class="r-chart-container" id="r-density-chart"></div>
16390        </div>
16391        <div class="r-viz-card">
16392          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
16393            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
16394            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16395          </div>
16396          <div class="r-chart-container" id="r-avglines-chart"></div>
16397        </div>
16398        <div class="r-viz-card">
16399          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
16400            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
16401            <select class="r-chart-select" id="r-sub-metric">
16402              <option value="code">Code Lines</option>
16403              <option value="comment">Comments</option>
16404              <option value="blank">Blank Lines</option>
16405              <option value="physical">Physical Lines</option>
16406              <option value="files">Files</option>
16407            </select>
16408            <select class="r-chart-select" id="r-sub-sort">
16409              <option value="desc">Value ↓</option>
16410              <option value="asc">Value ↑</option>
16411              <option value="name">Name A→Z</option>
16412            </select>
16413            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922;</button>
16414          </div>
16415          <div class="r-chart-container" id="r-submodule-chart"></div>
16416        </div>
16417      </div>
16418
16419    </section>
16420    </div>
16421
16422  </div>
16423
16424  <div id="r-tt" aria-hidden="true"></div>
16425
16426  <script nonce="{{ csp_nonce }}">
16427    (function () {
16428      var body = document.body;
16429      var themeToggle = document.getElementById('theme-toggle');
16430      var storageKey = 'oxide-sloc-theme';
16431
16432      function applyTheme(theme) {
16433        body.classList.toggle('dark-theme', theme === 'dark');
16434      }
16435
16436      function loadSavedTheme() {
16437        try {
16438          var saved = localStorage.getItem(storageKey);
16439          if (saved === 'dark' || saved === 'light') {
16440            applyTheme(saved);
16441          }
16442        } catch (e) {}
16443      }
16444
16445      if (themeToggle) {
16446        themeToggle.addEventListener('click', function () {
16447          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
16448          applyTheme(nextTheme);
16449          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
16450        });
16451      }
16452
16453      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
16454        button.addEventListener('click', function () {
16455          var value = button.getAttribute('data-copy-value') || '';
16456          if (!value) return;
16457          var originalText = button.textContent;
16458          function flashSuccess() {
16459            button.textContent = 'Copied!';
16460            setTimeout(function () { button.textContent = originalText; }, 1800);
16461          }
16462          function flashFail() {
16463            button.textContent = 'Copy failed';
16464            setTimeout(function () { button.textContent = originalText; }, 2000);
16465          }
16466          if (navigator.clipboard && navigator.clipboard.writeText) {
16467            navigator.clipboard.writeText(value).then(flashSuccess, function () {
16468              fallbackCopy(value, flashSuccess, flashFail);
16469            });
16470          } else {
16471            fallbackCopy(value, flashSuccess, flashFail);
16472          }
16473        });
16474      });
16475      function fallbackCopy(text, onSuccess, onFail) {
16476        try {
16477          var ta = document.createElement('textarea');
16478          ta.value = text;
16479          ta.style.position = 'fixed';
16480          ta.style.top = '-9999px';
16481          ta.style.left = '-9999px';
16482          document.body.appendChild(ta);
16483          ta.focus();
16484          ta.select();
16485          var ok = document.execCommand('copy');
16486          document.body.removeChild(ta);
16487          if (ok) { onSuccess(); } else { onFail(); }
16488        } catch (e) { onFail(); }
16489      }
16490
16491      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
16492        btn.addEventListener('click', function () {
16493          var folder = btn.getAttribute('data-folder') || '';
16494          if (!folder) return;
16495          fetch('/open-path?path=' + encodeURIComponent(folder))
16496            .then(function (r) { return r.json(); })
16497            .then(function (d) {
16498              if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
16499            })
16500            .catch(function () {});
16501        });
16502      });
16503
16504      loadSavedTheme();
16505
16506      // ── Compact number formatting for stat chips ──────────────────────────
16507      (function(){
16508        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();}
16509        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
16510          var raw=parseInt(chip.getAttribute('data-raw'),10);
16511          if(isNaN(raw))return;
16512          var valEl=chip.querySelector('.stat-chip-val');
16513          if(valEl)valEl.textContent=fmt(raw);
16514          var exactEl=chip.querySelector('.stat-chip-exact');
16515          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
16516        });
16517        // Code density chip
16518        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
16519          var code=parseInt(chip.getAttribute('data-code'),10);
16520          var phys=parseInt(chip.getAttribute('data-physical'),10);
16521          if(isNaN(code)||isNaN(phys)||phys===0)return;
16522          var pct=(code/phys*100).toFixed(1)+'%';
16523          var valEl=chip.querySelector('.stat-chip-val');
16524          if(valEl)valEl.textContent=pct;
16525        });
16526        // Populate author handle from data-author attribute
16527        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
16528          var author=chip.getAttribute('data-author');
16529          var el=chip.querySelector('.author-handle');
16530          if(el)el.textContent='/'+author.replace(/\s+/g,'');
16531        });
16532        // Click-to-copy on run-id-chip elements
16533        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
16534          chip.addEventListener('click',function(){
16535            var val=chip.getAttribute('data-copy');
16536            if(!val)return;
16537            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
16538            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);}
16539            chip.classList.add('chip-copied-flash');
16540            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
16541          });
16542        });
16543      })();
16544
16545      // ── Shared tooltip for all result-page charts ─────────────────────────
16546      var rTT=(function(){
16547        var el=document.getElementById('r-tt');
16548        if(!el)return{s:function(){},h:function(){},m:function(){}};
16549        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
16550        function hide(){el.style.display='none';}
16551        function move(e){
16552          var x=e.clientX+16,y=e.clientY-12;
16553          var r=el.getBoundingClientRect();
16554          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
16555          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
16556          el.style.left=x+'px';el.style.top=y+'px';
16557        }
16558        return{s:show,h:hide,m:move};
16559      })();
16560      window.rTT=rTT;
16561
16562      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
16563      (function(){
16564        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16565        document.addEventListener('mouseover',function(e){
16566          var t=e.target;
16567          while(t&&t.getAttribute){
16568            var l=t.getAttribute('data-ttl');
16569            if(l!==null){
16570              var v=t.getAttribute('data-ttv')||'';
16571              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
16572              return;
16573            }
16574            t=t.parentNode;
16575          }
16576        });
16577        document.addEventListener('mouseout',function(e){
16578          var t=e.target;
16579          while(t&&t.getAttribute){
16580            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
16581            t=t.parentNode;
16582          }
16583        });
16584        document.addEventListener('mousemove',function(e){
16585          var el=document.getElementById('r-tt');
16586          if(el&&el.style.display!=='none')rTT.m(e);
16587        });
16588      })();
16589
16590      // ── Language overview charts ───────────────────────────────────────────
16591      (function(){
16592        var D={{ lang_chart_json|safe }};
16593        if(!D||!D.length)return;
16594        var el=document.getElementById('result-lang-charts');
16595        if(!el)return;
16596        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16597        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
16598        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16599        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();}
16600        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16601        function px(n){return Math.round(n);}
16602        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+'"';}
16603        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
16604
16605        // Donut chart — height matches the stacked-bar chart so both panels align
16606        var rHb_d=28;
16607        var DH=Math.max(220,D.length*rHb_d+32);
16608        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
16609        var legX=204,DW=360;
16610        var legCount=D.length;
16611        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
16612        var legYStart=Math.round((DH-legCount*legSpacing)/2);
16613        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">';
16614        if(D.length===1){
16615          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
16616          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+'"/>';
16617        } else {
16618          var ang=-Math.PI/2;
16619          D.forEach(function(d,i){
16620            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
16621            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
16622            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
16623            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
16624            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
16625            var pct=Math.round(d.code/tot*100);
16626            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"/>';
16627            ang+=sw;
16628          });
16629        }
16630        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
16631        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
16632        D.forEach(function(d,i){
16633          var ly=legYStart+i*legSpacing;
16634          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
16635          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
16636        });
16637        ds+='</svg>';
16638
16639        // Horizontal stacked-bar chart — fills container width
16640        var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
16641        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
16642        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">';
16643        D.forEach(function(d,i){
16644          var y=6+i*rHb,x=LW;
16645          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
16646          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>';
16647          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;
16648          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;
16649          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"/>';
16650          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>';
16651        });
16652        var ly=SH-14;
16653        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>';
16654        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>';
16655        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>';
16656        bs+='</svg>';
16657        el.innerHTML='<div class="r-lang-overview">'+
16658          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
16659          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
16660        '</div>';
16661      })();
16662
16663      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
16664      (function(){
16665        var LANG_D={{ lang_chart_json|safe }};
16666        var SCAT_D={{ scatter_chart_json|safe }};
16667        var SEM_D={{ semantic_chart_json|safe }};
16668        var SUB_D={{ submodule_chart_json|safe }};
16669        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
16670        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
16671        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();}
16672        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16673        function px(n){return Math.round(n);}
16674        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+'"';}
16675
16676        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
16677        function renderCompositionInEl(el,mode,shOvr){
16678          if(!el||!LANG_D||!LANG_D.length)return;
16679          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
16680          var LW=110,SH=shOvr||224;
16681          var svgW=Math.max(320,el.offsetWidth||480);
16682          var BW=Math.max(120,svgW-LW-80);
16683          var legendH=24,topPad=4;
16684          var n=LANG_D.length||1;
16685          var rowTotal=Math.floor((SH-legendH-topPad)/n);
16686          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16687          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">';
16688          if(mode==='pct'){
16689            LANG_D.forEach(function(d,i){
16690              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
16691              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
16692              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16693              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>';
16694              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;
16695              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;
16696              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+'"/>';
16697              var pct=Math.round((d.code||0)/tot2*100);
16698              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
16699            });
16700          } else {
16701            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
16702            LANG_D.forEach(function(d,i){
16703              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
16704              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
16705              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>';
16706              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;
16707              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;
16708              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+'"/>';
16709              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>';
16710            });
16711          }
16712          var ly=SH-legendH+4;
16713          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>';
16714          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>';
16715          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>';
16716          s+='</svg>';
16717          el.innerHTML=s;
16718        }
16719        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
16720        renderComposition('abs');
16721        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
16722          btn.addEventListener('click',function(){
16723            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
16724            btn.classList.add('active');
16725            renderComposition(btn.getAttribute('data-rcomp'));
16726          });
16727        });
16728
16729        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
16730        function renderScatterInEl(el,hOvr){
16731          if(!el||!SCAT_D||!SCAT_D.length)return;
16732          var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
16733          var W=Math.max(320,el.offsetWidth||480);
16734          var cW=W-PL-PR,cH=H-PT-PB;
16735          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
16736          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
16737          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
16738          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">';
16739          [0,0.25,0.5,0.75,1].forEach(function(t){
16740            var y=PT+cH*(1-t);
16741            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
16742            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>';
16743          });
16744          [0,0.25,0.5,0.75,1].forEach(function(t){
16745            var x=PL+cW*t;
16746            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
16747            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>';
16748          });
16749          SCAT_D.forEach(function(d,i){
16750            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
16751            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
16752            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"/>';
16753            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>';
16754          });
16755          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>';
16756          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>';
16757          s+='</svg>';
16758          el.innerHTML=s;
16759        }
16760        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
16761
16762        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
16763        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
16764        // the old vertical column layout on wide containers.
16765        function renderSemanticInEl(el,key,sh){
16766          if(!el||!SEM_D||!SEM_D.length)return;
16767          var LW=112,SH=sh||224;
16768          var svgW=Math.max(320,el.offsetWidth||480);
16769          var BW=Math.max(120,svgW-LW-80);
16770          var topPad=4,botPad=14;
16771          var n2=SEM_D.length||1;
16772          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
16773          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
16774          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
16775          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">';
16776          SEM_D.forEach(function(d,i){
16777            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
16778            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>';
16779            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"/>';
16780            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>';
16781          });
16782          s+='</svg>';
16783          el.innerHTML=s;
16784        }
16785        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,224);}
16786        var semSel=document.getElementById('r-semantic-metric');
16787        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
16788        var semExpand=document.getElementById('r-semantic-expand');
16789        if(semExpand){
16790          semExpand.addEventListener('click',function(){
16791            var key=semSel?semSel.value:'functions';
16792            var n=SEM_D.length||1;
16793            var modalH=Math.max(624,n*62+96);
16794            var overlay=document.createElement('div');
16795            overlay.className='r-chart-modal-overlay';
16796            overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button><span class="r-chart-modal-title">Semantic Metrics — Full View</span><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
16797            document.body.appendChild(overlay);
16798            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
16799            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
16800            var modalEl=document.getElementById('r-sem-modal-chart');
16801            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
16802          });
16803        }
16804
16805        // ── Expand buttons: re-render charts at large size inside modal ──────────
16806        (function(){
16807          function makeExpandModal(title,mH){
16808            var overlay=document.createElement('div');
16809            overlay.className='r-chart-modal-overlay';
16810            overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button><span class="r-chart-modal-title">'+title+' — Full View</span><div class="r-expand-modal-chart" style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
16811            document.body.appendChild(overlay);
16812            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
16813            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
16814            return overlay.querySelector('.r-expand-modal-chart');
16815          }
16816          var compExpandBtn=document.getElementById('r-composition-expand');
16817          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
16818            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
16819            var n=LANG_D.length||1;var mH=Math.max(624,n*62+96);
16820            var wrap=makeExpandModal('Language Composition',mH);
16821            if(wrap)setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
16822          });}
16823          var scatExpandBtn=document.getElementById('r-scatter-expand');
16824          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
16825            var wrap=makeExpandModal('Files vs Code Lines',672);
16826            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
16827          });}
16828          var densExpandBtn=document.getElementById('r-density-expand');
16829          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
16830            var n=LANG_D.length||1;var mH=Math.max(624,n*62+96);
16831            var wrap=makeExpandModal('Comment Density',mH);
16832            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
16833          });}
16834          var avgExpandBtn=document.getElementById('r-avglines-expand');
16835          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
16836            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=Math.max(624,n*62+96);
16837            var wrap=makeExpandModal('Avg Lines per File',mH);
16838            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
16839          });}
16840          var subExpandBtn=document.getElementById('r-submodule-expand');
16841          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
16842            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
16843            var n=(SUB_D.length+1)||1;var mH=Math.max(624,n*43+96);
16844            var wrap=makeExpandModal('Repository Overview',mH);
16845            if(wrap)setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
16846          });}
16847        })();
16848
16849        // ── Comment Density: comments / (code + comments) per language ───────────
16850        function renderDensityInEl(el,shOvr){
16851          if(!el||!LANG_D||!LANG_D.length)return;
16852          var LW=112,SH=shOvr||224;
16853          var svgW=Math.max(320,el.offsetWidth||480);
16854          var BW=Math.max(120,svgW-LW-80);
16855          var topPad=4,botPad=26;
16856          var n=LANG_D.length||1;
16857          var rowTotal=Math.floor((SH-topPad-botPad)/n);
16858          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16859          var densities=LANG_D.map(function(d){
16860            var sig=(d.code||0)+(d.comments||0);
16861            return sig>0?(d.comments||0)/sig:0;
16862          });
16863          var maxDen=Math.max.apply(null,densities)||1;
16864          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">';
16865          LANG_D.forEach(function(d,i){
16866            var den=densities[i],bw=den/maxDen*BW;
16867            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
16868            var pct=Math.round(den*100);
16869            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>';
16870            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"/>';
16871            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
16872            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>';
16873          });
16874          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>';
16875          s+='</svg>';
16876          el.innerHTML=s;
16877        }
16878        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
16879        renderDensity();
16880
16881        // ── Avg Lines per File: code / files per language ─────────────────────
16882        function renderAvgLinesInEl(el,shOvr){
16883          if(!el||!LANG_D||!LANG_D.length)return;
16884          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
16885          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
16886          var LW=112,SH=shOvr||224;
16887          var svgW=Math.max(320,el.offsetWidth||480);
16888          var BW=Math.max(120,svgW-LW-80);
16889          var topPad=4,botPad=26;
16890          var n=data.length||1;
16891          var rowTotal=Math.floor((SH-topPad-botPad)/n);
16892          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
16893          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
16894          var maxAvg=Math.max.apply(null,avgs)||1;
16895          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">';
16896          data.forEach(function(d,i){
16897            var avg=avgs[i],bw=avg/maxAvg*BW;
16898            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
16899            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>';
16900            if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file · '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
16901            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
16902            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(Math.round(avg))+'</text>';
16903          });
16904          s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.5">avg code lines per file (higher = larger files)</text>';
16905          s+='</svg>';
16906          el.innerHTML=s;
16907        }
16908        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
16909        renderAvgLines();
16910
16911        // ── Repository Overview: overall row + per-submodule rows ────────────
16912        function renderSubmoduleInEl(el,key,sort,shOvr){
16913          if(!el)return;
16914          var overall={
16915            name:'Overall',
16916            code:LANG_D.reduce(function(s,d){return s+(d.code||0);},0),
16917            comment:LANG_D.reduce(function(s,d){return s+(d.comments||0);},0),
16918            blank:LANG_D.reduce(function(s,d){return s+(d.blanks||0);},0),
16919            physical:SCAT_D.reduce(function(s,d){return s+(d.physical||0);},0),
16920            files:LANG_D.reduce(function(s,d){return s+(d.files||0);},0),
16921            isOverall:true
16922          };
16923          var subs=SUB_D.slice();
16924          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
16925          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
16926          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
16927          var data=[overall].concat(subs);
16928          var rowH=32,bH=22,sepH=subs.length>0?14:0;
16929          var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
16930          var svgW=Math.max(320,el.offsetWidth||480);
16931          var LW=116,BW=Math.max(200,svgW-LW-54);
16932          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
16933          var OVERALL_COL='#6b7280';
16934          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">';
16935          var yOff=4;
16936          data.forEach(function(d,i){
16937            var v=d[key]||0,bw=v/maxV*BW,y=yOff;
16938            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
16939            var label=d.name||d.path||'?';
16940            s+='<text x="'+(LW-5)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
16941            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
16942            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
16943            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;"'+(d.isOverall?' font-weight="700"':'')+'>'+fmt(v)+'</text>';
16944            yOff+=rowH;
16945            if(d.isOverall&&subs.length>0){
16946              yOff+=sepH;
16947            }
16948          });
16949          s+='</svg>';
16950          el.innerHTML=s;
16951        }
16952        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
16953        var subSel=document.getElementById('r-sub-metric');
16954        var sortSel=document.getElementById('r-sub-sort');
16955        renderSubmodule('code','desc');
16956        if(subSel){
16957          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
16958          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
16959        }
16960
16961        // Re-render all SVG charts when the window is resized so bars fill the card.
16962        var _rResizeTimer;
16963        window.addEventListener('resize',function(){
16964          clearTimeout(_rResizeTimer);
16965          _rResizeTimer=setTimeout(function(){
16966            var rcompBtn=document.querySelector('[data-rcomp].active');
16967            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
16968            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
16969            if(semSel)renderSemantic(semSel.value||'functions');
16970            renderDensity();
16971            renderAvgLines();
16972            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
16973          },120);
16974        });
16975      })();
16976
16977      (function randomizeWatermarks() {
16978        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
16979        if (!wms.length) return;
16980        var placed = [];
16981        function tooClose(top, left) {
16982          for (var i = 0; i < placed.length; i++) {
16983            var dt = Math.abs(placed[i][0] - top);
16984            var dl = Math.abs(placed[i][1] - left);
16985            if (dt < 20 && dl < 18) return true;
16986          }
16987          return false;
16988        }
16989        function pick(leftBand) {
16990          for (var attempt = 0; attempt < 50; attempt++) {
16991            var top = Math.random() * 85 + 5;
16992            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
16993            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16994          }
16995          var top = Math.random() * 85 + 5;
16996          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
16997          placed.push([top, left]);
16998          return [top, left];
16999        }
17000        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
17001        var half = Math.floor(wms.length / 2);
17002        wms.forEach(function (img, i) {
17003          var pos = pick(i < half);
17004          var size = Math.floor(Math.random() * 100 + 160);
17005          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
17006          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
17007          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;
17008        });
17009      })();
17010
17011      (function spawnCodeParticles() {
17012        var container = document.getElementById('code-particles');
17013        if (!container) return;
17014        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'];
17015        for (var i = 0; i < 38; i++) {
17016          (function(idx) {
17017            var el = document.createElement('span');
17018            el.className = 'code-particle';
17019            el.textContent = snippets[idx % snippets.length];
17020            var left = Math.random() * 94 + 2;
17021            var top = Math.random() * 88 + 6;
17022            var dur = (Math.random() * 10 + 9).toFixed(1);
17023            var delay = (Math.random() * 18).toFixed(1);
17024            var rot = (Math.random() * 26 - 13).toFixed(1);
17025            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17026            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';
17027            container.appendChild(el);
17028          })(i);
17029        }
17030      })();
17031
17032      {% if pdf_generating %}
17033      // Poll for PDF readiness and swap the disabled button to a live link once done.
17034      (function() {
17035        var openBtn = document.getElementById('pdf-open-btn');
17036        var dlBtn = document.getElementById('pdf-download-btn');
17037        function checkPdf() {
17038          fetch('/api/runs/{{ run_id }}/pdf-status')
17039            .then(function(r) { return r.json(); })
17040            .then(function(d) {
17041              if (d.ready) {
17042                if (openBtn) {
17043                  var a = document.createElement('a');
17044                  a.className = 'button';
17045                  a.id = 'pdf-open-btn';
17046                  a.href = '/runs/pdf/{{ run_id }}';
17047                  a.target = '_blank';
17048                  a.rel = 'noopener';
17049                  a.textContent = 'Open PDF';
17050                  openBtn.replaceWith(a);
17051                }
17052                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
17053              } else {
17054                setTimeout(checkPdf, 3000);
17055              }
17056            })
17057            .catch(function() { setTimeout(checkPdf, 5000); });
17058        }
17059        setTimeout(checkPdf, 3000);
17060      })();
17061      {% endif %}
17062
17063    })();
17064  </script>
17065  <script nonce="{{ csp_nonce }}">
17066  (function(){
17067    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'}];
17068    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);});}
17069    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17070    function init(){
17071      var btn=document.getElementById('settings-btn');if(!btn)return;
17072      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17073      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>';
17074      document.body.appendChild(m);
17075      var g=document.getElementById('scheme-grid');
17076      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);});
17077      var cl=document.getElementById('settings-close');
17078      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);
17079      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');});
17080      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17081      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17082    }
17083    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17084  }());
17085  </script>
17086  <footer class="site-footer">
17087    local code analysis - metrics, history and reports
17088    &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>
17089    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17090    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17091    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17092    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17093  </footer>
17094  {% if confluence_configured %}
17095  <script nonce="{{ csp_nonce }}">
17096  (function() {
17097    var postBtn = document.getElementById('postConfluenceBtn');
17098    var copyBtn = document.getElementById('copyWikiBtn');
17099    var modal   = document.getElementById('confluenceModal');
17100    if (!postBtn || !modal) return;
17101
17102    postBtn.addEventListener('click', function() {
17103      document.getElementById('confStatus').style.display = 'none';
17104      modal.style.display = 'flex';
17105    });
17106    document.getElementById('confCancelBtn').addEventListener('click', function() {
17107      modal.style.display = 'none';
17108    });
17109    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17110
17111    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
17112      var btn = this;
17113      btn.disabled = true;
17114      var status = document.getElementById('confStatus');
17115      status.style.display = 'block';
17116      status.style.background = '#dbeafe';
17117      status.style.color = '#1e40af';
17118      status.textContent = 'Posting to Confluence…';
17119      var resp = await fetch('/api/confluence/post', {
17120        method: 'POST',
17121        headers: { 'Content-Type': 'application/json' },
17122        body: JSON.stringify({
17123          run_id: '{{ run_id }}',
17124          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
17125          report_url: document.getElementById('confReportUrl').value.trim() || null
17126        })
17127      });
17128      var data = await resp.json();
17129      if (data.ok) {
17130        status.style.background = '#dcfce7'; status.style.color = '#166534';
17131        status.textContent = 'Posted! Page ID: ' + data.page_id;
17132      } else {
17133        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17134        status.textContent = 'Error: ' + (data.error || 'Unknown error');
17135      }
17136      btn.disabled = false;
17137    });
17138
17139    if (copyBtn) {
17140      copyBtn.addEventListener('click', async function() {
17141        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
17142        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
17143        var text = await resp.text();
17144        try {
17145          await navigator.clipboard.writeText(text);
17146          var orig = copyBtn.textContent;
17147          copyBtn.textContent = 'Copied!';
17148          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
17149        } catch(e) {
17150          alert('Clipboard write failed — check browser permissions.');
17151        }
17152      });
17153    }
17154  })();
17155  </script>
17156  {% endif %}
17157  <script nonce="{{ csp_nonce }}">
17158  (function() {
17159    var deleteBtn = document.getElementById('delete-run-btn');
17160    var modal     = document.getElementById('delete-run-modal');
17161    var cancelBtn = document.getElementById('delete-run-cancel');
17162    var confirmBtn= document.getElementById('delete-run-confirm');
17163    if (!deleteBtn || !modal) return;
17164    deleteBtn.addEventListener('click', function() {
17165      document.getElementById('delete-run-status').style.display = 'none';
17166      modal.style.display = 'flex';
17167    });
17168    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
17169    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
17170    confirmBtn.addEventListener('click', async function() {
17171      confirmBtn.disabled = true;
17172      cancelBtn.disabled = true;
17173      var status = document.getElementById('delete-run-status');
17174      status.style.display = 'block';
17175      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
17176      status.textContent = 'Deleting…';
17177      try {
17178        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
17179        if (resp.status === 204 || resp.ok) {
17180          status.style.background = '#dcfce7'; status.style.color = '#166534';
17181          status.textContent = 'Deleted. Redirecting…';
17182          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
17183        } else {
17184          var d = await resp.json().catch(function(){return {};});
17185          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17186          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
17187          confirmBtn.disabled = false;
17188          cancelBtn.disabled = false;
17189        }
17190      } catch (e) {
17191        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
17192        status.textContent = 'Network error: ' + String(e);
17193        confirmBtn.disabled = false;
17194        cancelBtn.disabled = false;
17195      }
17196    });
17197  })();
17198  </script>
17199  <script nonce="{{ csp_nonce }}">(function(){
17200    var bundleBtn = document.getElementById('download-bundle-btn');
17201    if (bundleBtn) {
17202      bundleBtn.addEventListener('click', function() {
17203        bundleBtn.disabled = true;
17204        var orig = bundleBtn.textContent;
17205        bundleBtn.textContent = 'Preparing…';
17206        fetch('/api/runs/{{ run_id }}/bundle')
17207          .then(function(r) {
17208            if (!r.ok) throw new Error('HTTP ' + r.status);
17209            return r.blob();
17210          })
17211          .then(function(blob) {
17212            var url = URL.createObjectURL(blob);
17213            var a = document.createElement('a');
17214            a.href = url;
17215            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
17216            document.body.appendChild(a);
17217            a.click();
17218            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
17219            bundleBtn.disabled = false;
17220            bundleBtn.textContent = orig;
17221          })
17222          .catch(function(e) {
17223            bundleBtn.disabled = false;
17224            bundleBtn.textContent = orig;
17225            alert('Bundle download failed: ' + String(e));
17226          });
17227      });
17228    }
17229  })();</script>
17230  <script nonce="{{ csp_nonce }}">(function(){
17231    var dot=document.getElementById('status-dot');
17232    var pingEl=document.getElementById('server-ping-ms');
17233    var tipEl=document.getElementById('server-tip-ping');
17234    var fm=document.getElementById('footer-mode');
17235    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)';}}
17236    function doPing(){
17237      var t0=performance.now();
17238      fetch('/healthz',{cache:'no-store'})
17239        .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);})
17240        .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)';}});
17241    }
17242    doPing();
17243    setInterval(doPing,5000);
17244    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');}
17245  })();</script>
17246  {% if let Some(banner) = report_header_footer %}
17247  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
17248  {% endif %}
17249</body>
17250</html>
17251"##,
17252    ext = "html"
17253)]
17254// Template structs need many bool fields to pass Askama rendering flags.
17255#[allow(clippy::struct_excessive_bools)]
17256struct ResultTemplate {
17257    version: &'static str,
17258    report_title: String,
17259    project_path: String,
17260    output_dir: String,
17261    run_id: String,
17262    files_analyzed: u64,
17263    files_skipped: u64,
17264    physical_lines: u64,
17265    code_lines: u64,
17266    comment_lines: u64,
17267    blank_lines: u64,
17268    mixed_lines: u64,
17269    functions: u64,
17270    classes: u64,
17271    variables: u64,
17272    imports: u64,
17273    html_url: Option<String>,
17274    pdf_url: Option<String>,
17275    json_url: Option<String>,
17276    html_download_url: Option<String>,
17277    pdf_download_url: Option<String>,
17278    json_download_url: Option<String>,
17279    html_path: Option<String>,
17280    json_path: Option<String>,
17281    prev_run_id: Option<String>,
17282    prev_run_timestamp: Option<String>,
17283    prev_run_code_lines: Option<u64>,
17284    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
17285    prev_fa_str: String,
17286    prev_fs_str: String,
17287    prev_pl_str: String,
17288    prev_cl_str: String,
17289    prev_cml_str: String,
17290    prev_bl_str: String,
17291    // Signed change column for main metrics
17292    delta_fa_str: String,
17293    delta_fa_class: String,
17294    delta_fs_str: String,
17295    delta_fs_class: String,
17296    delta_pl_str: String,
17297    delta_pl_class: String,
17298    delta_cl_str: String,
17299    delta_cl_class: String,
17300    delta_cml_str: String,
17301    delta_cml_class: String,
17302    delta_bl_str: String,
17303    delta_bl_class: String,
17304    // delta vs previous scan
17305    delta_lines_added: Option<i64>,
17306    delta_lines_removed: Option<i64>,
17307    delta_lines_net_str: String,
17308    delta_lines_net_class: String,
17309    delta_files_added: Option<usize>,
17310    delta_files_removed: Option<usize>,
17311    delta_files_modified: Option<usize>,
17312    delta_files_unchanged: Option<usize>,
17313    delta_unmodified_lines: Option<u64>,
17314    // git context
17315    git_branch: Option<String>,
17316    git_commit: Option<String>,
17317    git_commit_long: Option<String>,
17318    git_author: Option<String>,
17319    git_commit_url: Option<String>,
17320    // scan metadata for hero section
17321    scan_performed_by: String,
17322    scan_time_display: String,
17323    generated_display: String,
17324    os_display: String,
17325    test_count: u64,
17326    // history
17327    prev_scan_count: usize,
17328    current_scan_number: usize,
17329    // submodule breakdown (empty when not requested)
17330    submodule_rows: Vec<SubmoduleRow>,
17331    scan_config_url: String,
17332    lang_chart_json: String,
17333    // Askama reads these via proc-macro expansion; clippy can't trace through it.
17334    #[allow(dead_code)]
17335    scatter_chart_json: String,
17336    #[allow(dead_code)]
17337    semantic_chart_json: String,
17338    #[allow(dead_code)]
17339    submodule_chart_json: String,
17340    #[allow(dead_code)]
17341    has_submodule_data: bool,
17342    #[allow(dead_code)]
17343    has_semantic_data: bool,
17344    pdf_generating: bool,
17345    csp_nonce: String,
17346    /// Whether Confluence integration is configured — shows Post button when true.
17347    confluence_configured: bool,
17348    server_mode: bool,
17349    /// Header/footer identification banner, mirrored from the HTML/PDF report.
17350    report_header_footer: Option<String>,
17351    run_id_short: String,
17352}
17353
17354#[derive(Template)]
17355#[template(
17356    source = r##"
17357<!doctype html>
17358<html lang="en">
17359<head>
17360  <meta charset="utf-8">
17361  <meta name="viewport" content="width=device-width, initial-scale=1">
17362  <title>OxideSLOC | Analyzing…</title>
17363  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17364  <style nonce="{{ csp_nonce }}">
17365    :root {
17366      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17367      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17368      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17369      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17370    }
17371    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17372    *{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;}
17373    .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);}
17374    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17375    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
17376    .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));}
17377    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17378    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
17379    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
17380    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17381    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17382    @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; } }
17383    .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;}
17384    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17385    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17386    .page-body{padding:32px 24px 36px;}
17387    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
17388    .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;}
17389    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
17390    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
17391    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
17392    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
17393    .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;}
17394    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
17395    .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;}
17396    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
17397    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
17398    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
17399    .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;}
17400    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
17401    .hidden{display:none!important;}
17402    .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;}
17403    .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;}
17404    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
17405    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
17406    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
17407    .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);}
17408    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
17409    .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;}
17410    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
17411    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17412    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17413    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
17414    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17415    .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;}
17416    @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));}}
17417    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17418    .site-footer a{color:var(--muted);}
17419    .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;}
17420    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
17421    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
17422    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
17423  </style>
17424</head>
17425<body>
17426  <div class="background-watermarks" aria-hidden="true">
17427    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17428    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17429    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17430    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17431    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17432    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17433  </div>
17434  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17435  <nav class="top-nav">
17436    <div class="top-nav-inner">
17437      <a href="/" class="brand">
17438        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
17439        <div class="brand-copy">
17440          <h1 class="brand-title">OxideSLOC</h1>
17441          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17442        </div>
17443      </a>
17444      <div class="nav-right">
17445        <a class="nav-pill" href="/">Home</a>
17446        <div class="nav-dropdown">
17447          <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>
17448          <div class="nav-dropdown-menu">
17449            <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>
17450          </div>
17451        </div>
17452        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17453        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17454        <div class="nav-dropdown">
17455          <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>
17456          <div class="nav-dropdown-menu">
17457            <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>
17458          </div>
17459        </div>
17460        <div class="server-status-wrap" id="server-status-wrap">
17461          <div class="nav-pill server-online-pill" id="server-status-pill">
17462            <span class="status-dot" id="status-dot"></span>
17463            <span id="server-status-label">Server</span>
17464            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17465          </div>
17466          <div class="server-status-tip">
17467            OxideSLOC is running — accessible on your network.
17468            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17469          </div>
17470        </div>
17471        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17472          <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>
17473        </button>
17474        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17475          <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>
17476          <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>
17477        </button>
17478      </div>
17479    </div>
17480  </nav>
17481  <div class="page-body">
17482    <div class="wait-panel">
17483      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
17484      <h2 class="wait-title">Analyzing your project…</h2>
17485      <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
17486      <div class="path-block">{{ project_path }}</div>
17487      <div class="metrics-row">
17488        <div class="metric-card">
17489          <div class="metric-label">Elapsed</div>
17490          <div class="metric-value" id="elapsed">0s</div>
17491        </div>
17492        <div class="metric-card">
17493          <div class="metric-label">Phase</div>
17494          <div class="metric-value" id="phase">Starting</div>
17495        </div>
17496      </div>
17497      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
17498      <div class="warn-slow hidden" id="warn-slow">
17499        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.
17500      </div>
17501      <div class="err-panel hidden" id="err-panel">
17502        <strong>Analysis failed</strong>
17503        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
17504      </div>
17505      <div class="actions hidden" id="actions">
17506        <a href="/scan" class="btn-primary">Try Again</a>
17507        <a href="/view-reports" class="btn-outline">View Reports</a>
17508      </div>
17509    </div>
17510  </div>
17511  <script nonce="{{ csp_nonce }}">
17512    (function() {
17513      var WAIT_ID = {{ wait_id_json|safe }};
17514      var startTime = Date.now();
17515      var pollInterval = 1500;
17516      var retries = 0;
17517      var maxRetries = 5;
17518      var warnShown = false;
17519
17520      function elapsed() {
17521        return Math.floor((Date.now() - startTime) / 1000);
17522      }
17523
17524      function updateElapsed() {
17525        var s = elapsed();
17526        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
17527      }
17528
17529      function setPhase(txt) {
17530        document.getElementById('phase').textContent = txt;
17531      }
17532
17533      var elapsedTimer = setInterval(updateElapsed, 1000);
17534
17535      function poll() {
17536        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
17537          .then(function(r) {
17538            if (!r.ok) throw new Error('HTTP ' + r.status);
17539            return r.json();
17540          })
17541          .then(function(data) {
17542            retries = 0;
17543            if (data.state === 'complete') {
17544              clearInterval(elapsedTimer);
17545              setPhase('Done');
17546              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
17547            } else if (data.state === 'failed') {
17548              clearInterval(elapsedTimer);
17549              setPhase('Failed');
17550              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
17551              document.getElementById('err-panel').classList.remove('hidden');
17552              document.getElementById('actions').classList.remove('hidden');
17553            } else {
17554              // still running
17555              var s = elapsed();
17556              if (s > 90 && !warnShown) {
17557                warnShown = true;
17558                document.getElementById('warn-slow').classList.remove('hidden');
17559              }
17560              setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
17561              setTimeout(poll, pollInterval);
17562            }
17563          })
17564          .catch(function(err) {
17565            retries++;
17566            if (retries >= maxRetries) {
17567              clearInterval(elapsedTimer);
17568              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
17569              document.getElementById('err-panel').classList.remove('hidden');
17570              document.getElementById('actions').classList.remove('hidden');
17571            } else {
17572              // exponential back-off capped at 8s
17573              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
17574            }
17575          });
17576      }
17577
17578      setTimeout(poll, pollInterval);
17579    })();
17580  </script>
17581  <footer class="site-footer">
17582    local code analysis - metrics, history and reports
17583    &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>
17584    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17585    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17586    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17587    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17588  </footer>
17589  <script nonce="{{ csp_nonce }}">
17590    (function(){
17591      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
17592      if(s==="dark")b.classList.add("dark-theme");
17593      var tt=document.getElementById("theme-toggle");
17594      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
17595    })();
17596    (function spawnCodeParticles(){
17597      var c=document.getElementById('code-particles');if(!c)return;
17598      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'];
17599      for(var i=0;i<32;i++){(function(idx){
17600        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
17601        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
17602        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
17603        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
17604        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
17605        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
17606        c.appendChild(el);
17607      })(i);}
17608    })();
17609    (function randomizeWatermarks(){
17610      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17611      var placed=[];
17612      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;}
17613      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];}
17614      var half=Math.floor(wms.length/2);
17615      wms.forEach(function(img,i){
17616        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
17617        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
17618        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
17619        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
17620        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
17621        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
17622      });
17623    })();
17624  </script>
17625  <script nonce="{{ csp_nonce }}">
17626  (function(){
17627    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'}];
17628    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);});}
17629    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17630    function init(){
17631      var btn=document.getElementById('settings-btn');if(!btn)return;
17632      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17633      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>';
17634      document.body.appendChild(m);
17635      var g=document.getElementById('scheme-grid');
17636      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);});
17637      var cl=document.getElementById('settings-close');
17638      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);
17639      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');});
17640      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17641      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17642    }
17643    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17644  }());
17645  </script>
17646  <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>
17647</body>
17648</html>
17649"##,
17650    ext = "html"
17651)]
17652struct ScanWaitTemplate {
17653    version: &'static str,
17654    wait_id_json: String,
17655    project_path: String,
17656    csp_nonce: String,
17657}
17658
17659#[derive(Template)]
17660#[template(
17661    source = r##"
17662<!doctype html>
17663<html lang="en">
17664<head>
17665  <meta charset="utf-8">
17666  <meta name="viewport" content="width=device-width, initial-scale=1">
17667  <title>OxideSLOC | Error</title>
17668  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17669  <style nonce="{{ csp_nonce }}">
17670    :root {
17671      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17672      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17673      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17674      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17675    }
17676    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17677    *{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;}
17678    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17679    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17680    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
17681    .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);}
17682    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17683    .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));}
17684    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17685    .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;}
17686    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17687    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17688    @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; } }
17689    .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;}
17690    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17691    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17692    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17693    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17694    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17695    .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;}
17696    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17697    .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);}
17698    .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;}
17699    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17700    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17701    .settings-modal-body{padding:14px 16px 16px;}
17702    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17703    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17704    .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;}
17705    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17706    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17707    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17708    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17709    .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;}
17710    .tz-select:focus{border-color:var(--oxide);}
17711    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
17712    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
17713    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
17714    .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;}
17715    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
17716    .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);}
17717    .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;}
17718    .btn-secondary:hover{background:var(--line);}
17719    .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;}
17720    .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;}
17721    .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;}
17722    @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));}}
17723    .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;}
17724  </style>
17725</head>
17726<body>
17727  <div class="background-watermarks" aria-hidden="true">
17728    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17729    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17730    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17731    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17732    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17733    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17734  </div>
17735  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17736  <div class="top-nav">
17737    <div class="top-nav-inner">
17738      <a class="brand" href="/">
17739        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17740        <div class="brand-copy">
17741          <div class="brand-title">OxideSLOC</div>
17742          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17743        </div>
17744      </a>
17745      <div class="nav-right">
17746        <a class="nav-pill" href="/">Home</a>
17747        <div class="nav-dropdown">
17748          <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>
17749          <div class="nav-dropdown-menu">
17750            <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>
17751          </div>
17752        </div>
17753        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17754        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17755        <div class="nav-dropdown">
17756          <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>
17757          <div class="nav-dropdown-menu">
17758            <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>
17759          </div>
17760        </div>
17761        <div class="server-status-wrap" id="server-status-wrap">
17762          <div class="nav-pill server-online-pill" id="server-status-pill">
17763            <span class="status-dot" id="status-dot"></span>
17764            <span id="server-status-label">Server</span>
17765            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17766          </div>
17767          <div class="server-status-tip">
17768            OxideSLOC is running — accessible on your network.
17769            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17770          </div>
17771        </div>
17772        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17773          <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>
17774        </button>
17775        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17776          <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>
17777          <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>
17778        </button>
17779      </div>
17780    </div>
17781  </div>
17782
17783  <div class="page">
17784    <div class="panel">
17785      <h1>Error</h1>
17786      <div class="error-box">{{ message }}</div>
17787      <div class="actions">
17788        <a class="btn-primary" href="/scan">Back to setup</a>
17789        {% if let Some(report_url) = last_report_url %}
17790        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
17791        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
17792        {% else %}
17793        <a class="btn-secondary" href="/view-reports">View Reports</a>
17794        {% endif %}
17795      </div>
17796    </div>
17797  </div>
17798  <script nonce="{{ csp_nonce }}">
17799    (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");});})();
17800    (function spawnCodeParticles() {
17801      var container = document.getElementById('code-particles');
17802      if (!container) return;
17803      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'];
17804      for (var i = 0; i < 38; i++) {
17805        (function(idx) {
17806          var el = document.createElement('span');
17807          el.className = 'code-particle';
17808          el.textContent = snippets[idx % snippets.length];
17809          var left = Math.random() * 94 + 2;
17810          var top = Math.random() * 88 + 6;
17811          var dur = (Math.random() * 10 + 9).toFixed(1);
17812          var delay = (Math.random() * 18).toFixed(1);
17813          var rot = (Math.random() * 26 - 13).toFixed(1);
17814          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17815          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';
17816          container.appendChild(el);
17817        })(i);
17818      }
17819    })();
17820    (function randomizeWatermarks() {
17821      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17822      var placed = [];
17823      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; }
17824      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]; }
17825      var half = Math.floor(wms.length/2);
17826      wms.forEach(function(img, i) {
17827        var pos = pick(i < half);
17828        var w = Math.floor(Math.random()*60+80);
17829        var rot = (Math.random()*40-20).toFixed(1);
17830        var op = (Math.random()*0.08+0.05).toFixed(2);
17831        var animDur = (Math.random()*6+5).toFixed(1);
17832        var animDelay = (Math.random()*10).toFixed(1);
17833        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';
17834      });
17835    })();
17836  </script>
17837  <script nonce="{{ csp_nonce }}">
17838  (function(){
17839    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'}];
17840    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);});}
17841    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17842    function init(){
17843      var btn=document.getElementById('settings-btn');if(!btn)return;
17844      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17845      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>';
17846      document.body.appendChild(m);
17847      var g=document.getElementById('scheme-grid');
17848      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);});
17849      var cl=document.getElementById('settings-close');
17850      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);
17851      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');});
17852      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17853      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17854    }
17855    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17856  }());
17857  </script>
17858  <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>
17859</body>
17860</html>
17861"##,
17862    ext = "html"
17863)]
17864struct ErrorTemplate {
17865    message: String,
17866    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
17867    last_report_url: Option<String>,
17868    /// Label for the secondary action button; defaults to "View last report" when None.
17869    last_report_label: Option<String>,
17870    csp_nonce: String,
17871    version: &'static str,
17872}
17873
17874// ── RelocateScanTemplate ──────────────────────────────────────────────────────
17875
17876#[derive(Template)]
17877#[template(
17878    source = r##"
17879<!doctype html>
17880<html lang="en">
17881<head>
17882  <meta charset="utf-8">
17883  <meta name="viewport" content="width=device-width, initial-scale=1">
17884  <title>OxideSLOC | Locate Scan Files</title>
17885  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17886  <style nonce="{{ csp_nonce }}">
17887    :root {
17888      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
17889      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
17890      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
17891      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
17892    }
17893    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
17894    *{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;}
17895    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17896    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17897    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
17898    .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);}
17899    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
17900    .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));}
17901    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17902    .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;}
17903    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
17904    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
17905    @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;}}
17906    .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;}
17907    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
17908    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
17909    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17910    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17911    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17912    .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;}
17913    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17914    .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);}
17915    .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;}
17916    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17917    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17918    .settings-modal-body{padding:14px 16px 16px;}
17919    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17920    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17921    .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;}
17922    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17923    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17924    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17925    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17926    .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;}
17927    .tz-select:focus{border-color:var(--oxide);}
17928    .page{max-width:860px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
17929    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
17930    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
17931    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
17932    .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;}
17933    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
17934    .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;}
17935    .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;}
17936    .btn-secondary:hover{background:var(--line);}
17937    .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;}
17938    .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;}
17939    .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;}
17940    @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));}}
17941    .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;}
17942    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
17943    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
17944    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
17945    .relocate-row{display:flex;gap:8px;align-items:stretch;}
17946    .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;}
17947    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
17948    body.dark-theme .relocate-input{background:var(--surface-2);}
17949  </style>
17950</head>
17951<body>
17952  <div class="background-watermarks" aria-hidden="true">
17953    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17954    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17955    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17956    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17957    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17958    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17959  </div>
17960  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17961  <div class="top-nav">
17962    <div class="top-nav-inner">
17963      <a class="brand" href="/">
17964        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17965        <div class="brand-copy">
17966          <div class="brand-title">OxideSLOC</div>
17967          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17968        </div>
17969      </a>
17970      <div class="nav-right">
17971        <a class="nav-pill" href="/">Home</a>
17972        <div class="nav-dropdown">
17973          <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>
17974          <div class="nav-dropdown-menu">
17975            <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>
17976          </div>
17977        </div>
17978        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
17979        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17980        <div class="nav-dropdown">
17981          <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>
17982          <div class="nav-dropdown-menu">
17983            <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>
17984          </div>
17985        </div>
17986        <div class="server-status-wrap" id="server-status-wrap">
17987          <div class="nav-pill server-online-pill" id="server-status-pill">
17988            <span class="status-dot" id="status-dot"></span>
17989            <span id="server-status-label">Server</span>
17990            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17991          </div>
17992          <div class="server-status-tip">
17993            OxideSLOC is running — accessible on your network.
17994            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17995          </div>
17996        </div>
17997        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17998          <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>
17999        </button>
18000        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18001          <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>
18002          <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>
18003        </button>
18004      </div>
18005    </div>
18006  </div>
18007
18008  <div class="page">
18009    <div class="panel">
18010      <h1>Scan Files Moved</h1>
18011      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
18012      <div class="error-box">{{ message }}</div>
18013      <div class="relocate-section">
18014        <h2>Locate Scan Output</h2>
18015        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
18016        <form method="post" action="/relocate-scan">
18017          <input type="hidden" name="run_id" value="{{ run_id }}">
18018          <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
18019          <div class="relocate-row">
18020            <input type="text" id="relocate-folder" name="folder_path"
18021                   value="{{ folder_hint }}"
18022                   placeholder="Path to folder containing scan output..."
18023                   class="relocate-input" autocomplete="off" spellcheck="false">
18024            {% if !server_mode %}
18025            <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
18026            {% endif %}
18027          </div>
18028          <div style="margin-top:12px;">
18029            <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
18030          </div>
18031        </form>
18032      </div>
18033      <div class="actions">
18034        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
18035        <a class="btn-secondary" href="/view-reports">View Reports</a>
18036      </div>
18037    </div>
18038  </div>
18039  <script nonce="{{ csp_nonce }}">
18040    (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");});})();
18041    (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);}})();
18042    (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';});})();
18043  </script>
18044  <script nonce="{{ csp_nonce }}">
18045  (function(){
18046    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'}];
18047    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);});}
18048    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18049    function init(){
18050      var btn=document.getElementById('settings-btn');if(!btn)return;
18051      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18052      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>';
18053      document.body.appendChild(m);
18054      var g=document.getElementById('scheme-grid');
18055      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);});
18056      var cl=document.getElementById('settings-close');
18057      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);
18058      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');});
18059      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18060      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18061    }
18062    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18063  }());
18064  (function(){
18065    var btn=document.getElementById('browse-relocate-btn');
18066    if(!btn)return;
18067    btn.addEventListener('click',function(){
18068      btn.disabled=true;btn.textContent='...';
18069      var inp=document.getElementById('relocate-folder');
18070      var hint=inp?inp.value:'';
18071      fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
18072        .then(function(r){return r.ok?r.json():{cancelled:true};})
18073        .then(function(d){
18074          btn.disabled=false;btn.textContent='Browse…';
18075          if(d&&d.selected_path&&inp)inp.value=d.selected_path;
18076        })
18077        .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
18078    });
18079  }());
18080  </script>
18081  <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>
18082</body>
18083</html>
18084"##,
18085    ext = "html"
18086)]
18087struct RelocateScanTemplate {
18088    message: String,
18089    run_id: String,
18090    folder_hint: String,
18091    redirect_url: String,
18092    server_mode: bool,
18093    csp_nonce: String,
18094    version: &'static str,
18095}
18096
18097// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
18098
18099#[derive(Template)]
18100#[template(
18101    source = r##"
18102<!doctype html>
18103<html lang="en">
18104<head>
18105  <meta charset="utf-8">
18106  <meta name="viewport" content="width=device-width, initial-scale=1">
18107  <title>OxideSLOC | View Reports</title>
18108  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18109  <style nonce="{{ csp_nonce }}">
18110    :root {
18111      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18112      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18113      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18114      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18115      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
18116    }
18117    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; }
18118    *{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;}
18119    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18120    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18121    .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);}
18122    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18123    .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));}
18124    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18125    .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;}
18126    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18127    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18128    @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; } }
18129    .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;}
18130    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18131    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18132    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18133    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18134    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18135    .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;}
18136    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18137    .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);}
18138    .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;}
18139    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18140    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18141    .settings-modal-body{padding:14px 16px 16px;}
18142    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18143    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18144    .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;}
18145    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18146    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18147    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18148    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18149    .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;}
18150    .tz-select:focus{border-color:var(--oxide);}
18151    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18152    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18153    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18154    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18155    .panel-meta{font-size:13px;color:var(--muted);}
18156    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18157    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18158    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18159    .per-page-label{font-size:13px;color:var(--muted);}
18160    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;}
18161    .filter-input{min-width:180px;cursor:text;}
18162    .table-wrap{width:100%;overflow-x:auto;}
18163    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
18164    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;}
18165    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18166    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18167    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18168    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18169    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18170    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18171    tr:last-child td{border-bottom:none;}
18172    tr:hover td{background:var(--surface-2);}
18173    .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);}
18174    .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);}
18175    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18176    .metric-num{font-weight:700;color:var(--text);}
18177    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18178    .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;}
18179    .btn:hover{background:var(--line);}
18180    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18181    .btn.primary:hover{opacity:.9;}
18182    .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;}
18183    .btn-back:hover{background:var(--line);}
18184    .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;}
18185    .export-btn:hover{background:var(--line);}
18186    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
18187    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
18188    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
18189    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18190    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18191    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18192    .pagination-info{font-size:13px;color:var(--muted);}
18193    .pagination-btns{display:flex;gap:6px;}
18194    .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;}
18195    .pg-btn:hover:not(:disabled){background:var(--line);}
18196    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18197    .pg-btn:disabled{opacity:.35;cursor:default;}
18198    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18199    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18200    .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;}
18201    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18202    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18203    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18204    .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);}
18205    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18206    .stat-chip:hover .stat-chip-tip{opacity:1;}
18207    .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;}
18208    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18209    .site-footer a{color:var(--muted);}
18210    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18211    .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%;}
18212    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
18213    .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;}
18214    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18215    .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;}
18216    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18217    .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;}
18218    .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;}
18219    .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;}
18220    @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));}}
18221    .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;}
18222    .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;}
18223    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18224    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18225    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18226    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18227    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18228    .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;}
18229    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18230    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18231    .watched-chip-rm:hover{color:var(--oxide);}
18232    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18233    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18234    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18235    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18236    .rpt-btn{min-width:58px;justify-content:center;}
18237    .flex-row{display:flex;align-items:center;gap:8px;}
18238    .report-cell{overflow:visible;white-space:normal;}
18239    #history-table col:nth-child(1){width:185px;}
18240    #history-table col:nth-child(2){width:220px;}
18241    #history-table col:nth-child(3){width:100px;}
18242    #history-table col:nth-child(4){width:72px;}
18243    #history-table col:nth-child(5){width:82px;}
18244    #history-table col:nth-child(6){width:82px;}
18245    #history-table col:nth-child(7){width:65px;}
18246    #history-table col:nth-child(8){width:90px;}
18247    #history-table col:nth-child(9){width:85px;}
18248    #history-table col:nth-child(10){width:115px;}
18249    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
18250    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
18251    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
18252    .submod-details summary::-webkit-details-marker{display:none;}
18253.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
18254    .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;}
18255    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
18256    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
18257  </style>
18258</head>
18259<body>
18260  <div class="background-watermarks" aria-hidden="true">
18261    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18262    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18263    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18264    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18265    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18266    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18267  </div>
18268  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18269  <div class="top-nav">
18270    <div class="top-nav-inner">
18271      <a class="brand" href="/">
18272        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18273        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
18274      </a>
18275      <div class="nav-right">
18276        <a class="nav-pill" href="/">Home</a>
18277        <div class="nav-dropdown">
18278          <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>
18279          <div class="nav-dropdown-menu">
18280            <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>
18281          </div>
18282        </div>
18283        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18284        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18285        <div class="nav-dropdown">
18286          <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>
18287          <div class="nav-dropdown-menu">
18288            <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>
18289          </div>
18290        </div>
18291        <div class="server-status-wrap" id="server-status-wrap">
18292          <div class="nav-pill server-online-pill" id="server-status-pill">
18293            <span class="status-dot" id="status-dot"></span>
18294            <span id="server-status-label">Server</span>
18295            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18296          </div>
18297          <div class="server-status-tip">
18298            OxideSLOC is running — accessible on your network.
18299            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18300          </div>
18301        </div>
18302        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18303          <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>
18304        </button>
18305        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18306          <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>
18307          <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>
18308        </button>
18309      </div>
18310    </div>
18311  </div>
18312
18313  <div class="page">
18314    {% if let Some(err) = browse_error %}
18315    <div class="toast-error">
18316      <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>
18317      {{ err }}
18318    </div>
18319    {% endif %}
18320    {% if linked_count > 0 %}
18321    <div class="toast-success">
18322      <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>
18323      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
18324    </div>
18325    {% endif %}
18326    <div class="watched-bar">
18327      <div class="watched-bar-left">
18328        <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>
18329        <span class="watched-label">Watched Folders</span>
18330        <div class="watched-chips">
18331          {% if server_mode %}
18332          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
18333          {% else %}
18334          {% for dir in watched_dirs %}
18335          <span class="watched-chip">
18336            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
18337            <form method="POST" action="/watched-dirs/remove" style="display:contents">
18338              <input type="hidden" name="folder_path" value="{{ dir }}">
18339              <input type="hidden" name="redirect_to" value="/view-reports">
18340              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
18341            </form>
18342          </span>
18343          {% endfor %}
18344          {% if watched_dirs.is_empty() %}
18345          <span class="watched-none">No folders watched — click Choose to add one</span>
18346          {% endif %}
18347          {% endif %}
18348        </div>
18349      </div>
18350      {% if !server_mode %}
18351      <div class="watched-bar-right">
18352        <button type="button" class="btn" id="add-watched-btn">
18353          <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>
18354          Choose
18355        </button>
18356        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
18357          <input type="hidden" name="redirect_to" value="/view-reports">
18358          <button type="submit" class="btn">&#8635; Refresh</button>
18359        </form>
18360      </div>
18361      {% endif %}
18362    </div>
18363    {% if total_scans > 0 %}
18364    <div class="summary-strip">
18365      <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>
18366      <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>
18367      <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>
18368      <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>
18369    </div>
18370    {% endif %}
18371
18372    <section class="panel">
18373      <div class="panel-header">
18374        <div>
18375          <h1>View Reports</h1>
18376          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
18377          {% 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 %}
18378        </div>
18379        <div class="flex-row">
18380          <button type="button" class="export-btn" id="export-csv-btn">
18381            <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>
18382            Export CSV
18383          </button>
18384          <button type="button" class="export-btn" id="export-xls-btn">
18385            <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>
18386            Export Excel
18387          </button>
18388        </div>
18389      </div>
18390
18391      {% if entries.is_empty() %}
18392      <div class="empty-state">
18393        <strong>No reports with viewable HTML yet</strong>
18394        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.
18395      </div>
18396      {% else %}
18397      <div class="filter-row">
18398        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project\u2026">
18399        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
18400        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
18401      </div>
18402      <div class="table-wrap">
18403        <table id="history-table">
18404          <colgroup>
18405            <col><col><col><col><col><col><col><col><col><col>
18406          </colgroup>
18407          <thead>
18408            <tr id="history-thead">
18409              <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>
18410              <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>
18411              <th>Run ID<div class="col-resize-handle"></div></th>
18412              <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>
18413              <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>
18414              <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>
18415              <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>
18416              <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>
18417              <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>
18418              <th>Report<div class="col-resize-handle"></div></th>
18419            </tr>
18420          </thead>
18421          <tbody id="history-tbody">
18422            {% for entry in entries %}
18423            <tr class="history-row" data-run="{{ entry.run_id }}"
18424                data-timestamp="{{ entry.timestamp }}"
18425                data-project="{{ entry.project_label }}"
18426                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
18427                data-skipped="{{ entry.files_skipped }}"
18428                data-comments="{{ entry.comment_lines }}"
18429                data-blank="{{ entry.blank_lines }}"
18430                data-branch="{{ entry.git_branch }}"
18431                data-commit="{{ entry.git_commit }}"
18432                data-html-url="/runs/html/{{ entry.run_id }}">
18433              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
18434              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
18435              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
18436              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
18437              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
18438              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
18439              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
18440              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
18441              <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>
18442              <td class="report-cell">
18443                <div class="actions-cell">
18444                  {% 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 %}
18445                  {% 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 %}
18446                </div>
18447                {% if !entry.submodule_links.is_empty() %}
18448                <details class="submod-details">
18449                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
18450                  <div class="submod-link-list">
18451                    {% for sub in entry.submodule_links %}
18452                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
18453                    {% endfor %}
18454                  </div>
18455                </details>
18456                {% endif %}
18457              </td>
18458            </tr>
18459            {% endfor %}
18460          </tbody>
18461        </table>
18462      </div>
18463      <div class="pagination">
18464        <span class="pagination-info" id="pagination-info"></span>
18465        <div class="pagination-btns" id="pagination-btns"></div>
18466        <div class="flex-row">
18467          <span class="per-page-label">Show</span>
18468          <select class="per-page" id="per-page-sel">
18469            <option value="10">10 per page</option>
18470            <option value="25" selected>25 per page</option>
18471            <option value="50">50 per page</option>
18472            <option value="100">100 per page</option>
18473          </select>
18474          <span class="per-page-label" id="page-range-label"></span>
18475        </div>
18476      </div>
18477      {% endif %}
18478    </section>
18479  </div>
18480
18481  <footer class="site-footer">
18482    local code analysis - metrics, history and reports
18483    &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>
18484    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
18485    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
18486    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
18487    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
18488  </footer>
18489
18490  <script nonce="{{ csp_nonce }}">
18491    (function () {
18492      // ── Theme ──────────────────────────────────────────────────────────────
18493      var storageKey = 'oxide-sloc-theme';
18494      var body = document.body;
18495      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
18496      var toggle = document.getElementById('theme-toggle');
18497      if (toggle) toggle.addEventListener('click', function () {
18498        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
18499        body.classList.toggle('dark-theme', next === 'dark');
18500        try { localStorage.setItem(storageKey, next); } catch(e) {}
18501      });
18502
18503      // ── State ─────────────────────────────────────────────────────────────
18504      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
18505      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
18506      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
18507
18508      // Aggregate stats from first (most recent) row
18509      if (allRows.length) {
18510        var first = allRows[0];
18511        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();}
18512        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>':'');}
18513        setChipVal('agg-code', first.dataset.code);
18514        setChipVal('agg-files', first.dataset.files);
18515        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
18516        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
18517      }
18518
18519      // ── Branch filter population ──────────────────────────────────────────
18520      (function() {
18521        var branches = {};
18522        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
18523        var sel = document.getElementById('branch-filter');
18524        if (sel) Object.keys(branches).sort().forEach(function(b) {
18525          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
18526        });
18527      })();
18528
18529      // ── Filter ────────────────────────────────────────────────────────────
18530      function getFilteredRows() {
18531        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
18532        var branch = ((document.getElementById('branch-filter') || {}).value || '');
18533        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
18534          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
18535          if (branch && (r.dataset.branch || '') !== branch) return false;
18536          return true;
18537        });
18538      }
18539
18540      // ── Pagination ────────────────────────────────────────────────────────
18541      function renderPage() {
18542        var filtered = getFilteredRows();
18543        var total = filtered.length;
18544        var totalPages = Math.max(1, Math.ceil(total / perPage));
18545        currentPage = Math.min(currentPage, totalPages);
18546        var start = (currentPage - 1) * perPage;
18547        var end = Math.min(start + perPage, total);
18548        var shown = {};
18549        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
18550        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
18551          r.style.display = shown[r.dataset.run] ? '' : 'none';
18552        });
18553        var rl = document.getElementById('page-range-label');
18554        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
18555        var info = document.getElementById('pagination-info');
18556        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
18557        var btns = document.getElementById('pagination-btns');
18558        if (!btns) return;
18559        btns.innerHTML = '';
18560        function makeBtn(lbl, pg, active, disabled) {
18561          var b = document.createElement('button');
18562          b.className = 'pg-btn' + (active ? ' active' : '');
18563          b.textContent = lbl; b.disabled = disabled;
18564          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
18565          return b;
18566        }
18567        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
18568        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
18569        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
18570        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
18571      }
18572
18573      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
18574      window.applyFilters = function() { currentPage = 1; renderPage(); };
18575
18576      // ── Sorting ───────────────────────────────────────────────────────────
18577      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
18578      function doSort(col, type, order) {
18579        var tbody = document.getElementById('history-tbody');
18580        if (!tbody) return;
18581        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
18582        rows.sort(function(a, b) {
18583          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
18584          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
18585          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
18586          return va < vb ? 1 : va > vb ? -1 : 0;
18587        });
18588        rows.forEach(function(r) { tbody.appendChild(r); });
18589        currentPage = 1; renderPage();
18590      }
18591      sortHeaders.forEach(function(th) {
18592        th.addEventListener('click', function(e) {
18593          if (e.target.classList.contains('col-resize-handle')) return;
18594          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
18595          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
18596          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18597          th.classList.add('sort-' + sortOrder);
18598          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
18599          doSort(col, type, sortOrder);
18600        });
18601      });
18602
18603      // ── Column resize ─────────────────────────────────────────────────────
18604      (function() {
18605        var table = document.getElementById('history-table');
18606        if (!table) return;
18607        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
18608        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
18609        ths.forEach(function(th, i) {
18610          var handle = th.querySelector('.col-resize-handle');
18611          if (!handle || !cols[i]) return;
18612          var startX, startW;
18613          handle.addEventListener('mousedown', function(e) {
18614            e.stopPropagation(); e.preventDefault();
18615            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
18616            handle.classList.add('dragging');
18617            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
18618            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
18619            document.addEventListener('mousemove', onMove);
18620            document.addEventListener('mouseup', onUp);
18621          });
18622        });
18623      })();
18624
18625      // ── Reset view ────────────────────────────────────────────────────────
18626      window.resetView = function() {
18627        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
18628        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
18629        sortCol = null; sortOrder = 'asc';
18630        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
18631        var tbody = document.getElementById('history-tbody');
18632        if (tbody) {
18633          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
18634          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
18635          rows.forEach(function(r) { tbody.appendChild(r); });
18636        }
18637        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
18638        var table = document.getElementById('history-table');
18639        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
18640        currentPage = 1; renderPage();
18641      };
18642
18643      renderPage();
18644
18645      // ── Export helpers ────────────────────────────────────────────────────
18646      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
18647      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
18648      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);}
18649      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;');}
18650      function slocXlsx(fname,sheet,hdrs,rows){
18651        var enc=new TextEncoder();
18652        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;}
18653        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;}
18654        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
18655        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
18656        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18657        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;}
18658        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];}
18659        var rx='<row r="1">';
18660        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
18661        rx+='</row>';
18662        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>';});
18663        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
18664        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>';
18665        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>';
18666        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>';
18667        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>',
18668          '_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>',
18669          '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>',
18670          '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>',
18671          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
18672        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'];
18673        var zparts=[],zcds=[],zoff=0,znf=0;
18674        order.forEach(function(name){
18675          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
18676          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]);
18677          var entry=new Uint8Array(lha.length+nb.length+sz);
18678          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
18679          zparts.push(entry);
18680          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));
18681          var cde=new Uint8Array(cda.length+nb.length);
18682          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
18683          zcds.push(cde);zoff+=entry.length;znf++;
18684        });
18685        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
18686        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]);
18687        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
18688        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
18689        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
18690        zout.set(new Uint8Array(ea),zpos);
18691        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
18692      }
18693
18694      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
18695      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;}
18696      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
18697      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
18698
18699      var csvBtn = document.getElementById('export-csv-btn');
18700      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
18701      var xlsBtn = document.getElementById('export-xls-btn');
18702      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
18703
18704      // ── Remaining CSP-safe event bindings ────────────────────────────────
18705      (function wireEvents() {
18706        var el;
18707        el = document.getElementById('reset-view-btn');
18708        if (el) el.addEventListener('click', window.resetView);
18709        el = document.getElementById('project-filter');
18710        if (el) el.addEventListener('input', window.applyFilters);
18711        el = document.getElementById('branch-filter');
18712        if (el) el.addEventListener('change', window.applyFilters);
18713        el = document.getElementById('per-page-sel');
18714        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
18715        el = document.getElementById('add-watched-btn');
18716        if (el) el.addEventListener('click', function() {
18717          fetch('/pick-directory?kind=reports')
18718            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
18719            .then(function(data) {
18720              if (!data.cancelled && data.selected_path) {
18721                var form = document.createElement('form');
18722                form.method = 'POST';
18723                form.action = '/watched-dirs/add';
18724                var ri = document.createElement('input');
18725                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
18726                var fi = document.createElement('input');
18727                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
18728                form.appendChild(ri); form.appendChild(fi);
18729                document.body.appendChild(form);
18730                form.submit();
18731              }
18732            })
18733            .catch(function(e) { alert('Could not open folder picker: ' + e); });
18734        });
18735      })();
18736
18737      (function randomizeWatermarks() {
18738        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18739        if (!wms.length) return;
18740        var placed = [];
18741        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;}
18742        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];}
18743        var half=Math.floor(wms.length/2);
18744        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;});
18745      })();
18746
18747      (function spawnCodeParticles() {
18748        var container = document.getElementById('code-particles');
18749        if (!container) return;
18750        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'];
18751        for (var i = 0; i < 38; i++) {
18752          (function(idx) {
18753            var el = document.createElement('span');
18754            el.className = 'code-particle';
18755            el.textContent = snippets[idx % snippets.length];
18756            var left = Math.random() * 94 + 2;
18757            var top = Math.random() * 88 + 6;
18758            var dur = (Math.random() * 10 + 9).toFixed(1);
18759            var delay = (Math.random() * 18).toFixed(1);
18760            var rot = (Math.random() * 26 - 13).toFixed(1);
18761            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18762            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';
18763            container.appendChild(el);
18764          })(i);
18765        }
18766      })();
18767    })();
18768  </script>
18769  <script nonce="{{ csp_nonce }}">
18770  (function(){
18771    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'}];
18772    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);});}
18773    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18774    function init(){
18775      var btn=document.getElementById('settings-btn');if(!btn)return;
18776      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18777      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>';
18778      document.body.appendChild(m);
18779      var g=document.getElementById('scheme-grid');
18780      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);});
18781      var cl=document.getElementById('settings-close');
18782      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);
18783      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');});
18784      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18785      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18786    }
18787    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18788  }());
18789  </script>
18790  <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>
18791</body>
18792</html>
18793"##,
18794    ext = "html"
18795)]
18796struct HistoryTemplate {
18797    version: &'static str,
18798    entries: Vec<HistoryEntryRow>,
18799    total_scans: usize,
18800    linked_count: usize,
18801    browse_error: Option<String>,
18802    watched_dirs: Vec<String>,
18803    csp_nonce: String,
18804    server_mode: bool,
18805}
18806
18807// ── CompareSelectTemplate ──────────────────────────────────────────────────────
18808
18809#[derive(Template)]
18810#[template(
18811    source = r##"
18812<!doctype html>
18813<html lang="en">
18814<head>
18815  <meta charset="utf-8">
18816  <meta name="viewport" content="width=device-width, initial-scale=1">
18817  <title>OxideSLOC | Compare Scans</title>
18818  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18819  <style nonce="{{ csp_nonce }}">
18820    :root {
18821      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
18822      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18823      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18824      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18825      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
18826    }
18827    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
18828    *{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;}
18829    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18830    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18831    .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);}
18832    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
18833    .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));}
18834    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
18835    .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;}
18836    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
18837    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18838    @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; } }
18839    .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;}
18840    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
18841    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
18842    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
18843    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18844    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18845    .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;}
18846    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18847    .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);}
18848    .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;}
18849    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18850    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18851    .settings-modal-body{padding:14px 16px 16px;}
18852    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18853    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18854    .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;}
18855    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18856    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18857    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18858    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18859    .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;}
18860    .tz-select:focus{border-color:var(--oxide);}
18861    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
18862    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
18863    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
18864    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18865    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
18866    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
18867    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
18868    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
18869    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
18870    .per-page-label{font-size:13px;color:var(--muted);}
18871    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;}
18872    .filter-input{min-width:180px;cursor:text;}
18873    .table-wrap{width:100%;overflow-x:auto;}
18874    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
18875    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;}
18876    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
18877    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
18878    #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;}
18879    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
18880    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
18881    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
18882    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
18883    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
18884    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
18885    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
18886    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
18887    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
18888    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
18889    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
18890    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
18891    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18892    tr:last-child td{border-bottom:none;}
18893    tr.selected td{background:var(--sel-bg);}
18894    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
18895    tr:hover:not(.selected) td{background:var(--surface-2);}
18896    tr{cursor:pointer;}
18897    .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);}
18898    .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);}
18899    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
18900    .metric-num{font-weight:700;color:var(--text);}
18901    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
18902    .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;}
18903    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
18904    .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;}
18905    .btn:hover{background:var(--line);}
18906    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
18907    .btn.primary:hover{opacity:.9;}
18908    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
18909    .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;}
18910    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
18911    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
18912    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
18913    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
18914    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
18915    .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;}
18916    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
18917    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
18918    .watched-chip-rm:hover{color:var(--oxide);}
18919    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
18920    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
18921    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
18922    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
18923    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
18924    .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;}
18925    .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;}
18926    .btn-back:hover{background:var(--line);}
18927    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
18928    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
18929    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
18930    .pagination-info{font-size:13px;color:var(--muted);}
18931    .pagination-btns{display:flex;gap:6px;}
18932    .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;}
18933    .pg-btn:hover:not(:disabled){background:var(--line);}
18934    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
18935    .pg-btn:disabled{opacity:.35;cursor:default;}
18936    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
18937    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18938    .site-footer a{color:var(--muted);}
18939    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
18940    .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;}
18941    .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;}
18942    .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;}
18943    @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));}}
18944    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
18945    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
18946    .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;}
18947    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
18948    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
18949    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
18950    .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);}
18951    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
18952    .stat-chip:hover .stat-chip-tip{opacity:1;}
18953    .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;}
18954    .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;}
18955    .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%;}
18956    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
18957    .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;}
18958    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
18959    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
18960    .hidden{display:none!important;}
18961    .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%;}
18962    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
18963    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
18964    .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;}
18965    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
18966    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
18967    .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;}
18968    .scope-option:hover{background:var(--line);}
18969    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
18970    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
18971    .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;}
18972    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
18973    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
18974    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
18975    .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;}
18976  </style>
18977</head>
18978<body>
18979  <div class="background-watermarks" aria-hidden="true">
18980    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18981    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18982    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18983    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18984    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18985    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18986  </div>
18987  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18988  <div class="top-nav">
18989    <div class="top-nav-inner">
18990      <a class="brand" href="/">
18991        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18992        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
18993      </a>
18994      <div class="nav-right">
18995        <a class="nav-pill" href="/">Home</a>
18996        <div class="nav-dropdown">
18997          <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>
18998          <div class="nav-dropdown-menu">
18999            <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>
19000          </div>
19001        </div>
19002        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19003        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19004        <div class="nav-dropdown">
19005          <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>
19006          <div class="nav-dropdown-menu">
19007            <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>
19008          </div>
19009        </div>
19010        <div class="server-status-wrap" id="server-status-wrap">
19011          <div class="nav-pill server-online-pill" id="server-status-pill">
19012            <span class="status-dot" id="status-dot"></span>
19013            <span id="server-status-label">Server</span>
19014            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19015          </div>
19016          <div class="server-status-tip">
19017            OxideSLOC is running — accessible on your network.
19018            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19019          </div>
19020        </div>
19021        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19022          <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>
19023        </button>
19024        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19025          <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>
19026          <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>
19027        </button>
19028      </div>
19029    </div>
19030  </div>
19031
19032  <div class="page">
19033    <div class="watched-bar">
19034      <div class="watched-bar-left">
19035        <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>
19036        <span class="watched-label">Watched Folders</span>
19037        <div class="watched-chips">
19038          {% if server_mode %}
19039          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
19040          {% else %}
19041          {% for dir in watched_dirs %}
19042          <span class="watched-chip">
19043            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
19044            <form method="POST" action="/watched-dirs/remove" style="display:contents">
19045              <input type="hidden" name="folder_path" value="{{ dir }}">
19046              <input type="hidden" name="redirect_to" value="/compare-scans">
19047              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
19048            </form>
19049          </span>
19050          {% endfor %}
19051          {% if watched_dirs.is_empty() %}
19052          <span class="watched-none">No folders watched — click Choose to add one</span>
19053          {% endif %}
19054          {% endif %}
19055        </div>
19056      </div>
19057      {% if !server_mode %}
19058      <div class="watched-bar-right">
19059        <button type="button" class="btn" id="add-watched-btn">
19060          <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>
19061          Choose
19062        </button>
19063        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
19064          <input type="hidden" name="redirect_to" value="/compare-scans">
19065          <button type="submit" class="btn">&#8635; Refresh</button>
19066        </form>
19067      </div>
19068      {% endif %}
19069    </div>
19070    {% if total_scans > 0 %}
19071    <div class="summary-strip">
19072      <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>
19073      <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>
19074      <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>
19075      <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>
19076    </div>
19077    {% endif %}
19078    <section class="panel">
19079      <div class="panel-header">
19080        <div>
19081          <h1>Compare Scans</h1>
19082          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
19083        </div>
19084        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
19085          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
19086            <button class="btn primary" id="compare-btn" disabled>
19087              <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>
19088              Compare <span class="sel-count" id="sel-count">0/2</span>
19089            </button>
19090          </div>
19091        </div>
19092      </div>
19093
19094      {% if entries.is_empty() %}
19095      <div class="empty-state">
19096        <strong>No scans yet</strong>
19097        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.
19098      </div>
19099      {% else %}
19100      <div class="filter-row">
19101        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project\u2026">
19102        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
19103        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
19104      </div>
19105      <div class="scope-panel hidden" id="scope-panel">
19106        <div class="scope-panel-label">
19107          <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>
19108          Compare scope — choose what to include
19109        </div>
19110        <div class="scope-options" id="scope-options"></div>
19111      </div>
19112      {% if total_scans > 0 %}
19113      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
19114        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
19115          <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>
19116          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
19117        </div>
19118      </div>
19119      {% endif %}
19120      <div class="table-wrap">
19121        <table id="compare-table">
19122          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
19123          <thead>
19124            <tr id="compare-thead">
19125              <th><div class="col-resize-handle"></div></th>
19126              <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>
19127              <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>
19128              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
19129              <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>
19130              <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>
19131              <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>
19132              <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>
19133              <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>
19134              <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>
19135              <th>Submodules<div class="col-resize-handle"></div></th>
19136            </tr>
19137          </thead>
19138          <tbody id="compare-tbody">
19139            {% for entry in entries %}
19140            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
19141                data-timestamp="{{ entry.timestamp }}"
19142                data-project="{{ entry.project_label }}"
19143                data-files="{{ entry.files_analyzed }}"
19144                data-code="{{ entry.code_lines }}"
19145                data-comments="{{ entry.comment_lines }}"
19146                data-blank="{{ entry.blank_lines }}"
19147                data-branch="{{ entry.git_branch }}"
19148                data-commit="{{ entry.git_commit }}"
19149                data-submodules="{{ entry.submodule_names_csv }}">
19150              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
19151              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
19152              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
19153              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
19154              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
19155              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
19156              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
19157              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
19158              <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>
19159              <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>
19160              <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>
19161            </tr>
19162            {% endfor %}
19163          </tbody>
19164        </table>
19165      </div>
19166      <div class="pagination">
19167        <span class="pagination-info" id="pagination-info"></span>
19168        <div class="pagination-btns" id="pagination-btns"></div>
19169        <div class="flex-row">
19170          <span class="per-page-label">Show</span>
19171          <select class="per-page" id="per-page-sel">
19172            <option value="10">10 per page</option>
19173            <option value="25" selected>25 per page</option>
19174            <option value="50">50 per page</option>
19175            <option value="100">100 per page</option>
19176          </select>
19177          <span class="per-page-label" id="page-range-label"></span>
19178        </div>
19179      </div>
19180      {% endif %}
19181    </section>
19182  </div>
19183
19184  <footer class="site-footer">
19185    local code analysis - metrics, history and reports
19186    &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>
19187    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19188    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19189    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19190    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19191  </footer>
19192
19193  <script nonce="{{ csp_nonce }}">
19194    (function () {
19195      // ── Theme ──────────────────────────────────────────────────────────────
19196      var storageKey = 'oxide-sloc-theme';
19197      var body = document.body;
19198      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19199      var toggle = document.getElementById('theme-toggle');
19200      if (toggle) toggle.addEventListener('click', function () {
19201        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19202        body.classList.toggle('dark-theme', next === 'dark');
19203        try { localStorage.setItem(storageKey, next); } catch(e) {}
19204      });
19205
19206      // ── State ─────────────────────────────────────────────────────────────
19207      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
19208      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
19209      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
19210
19211      // ── Stat chips ────────────────────────────────────────────────────────
19212      (function() {
19213        var projects = {}, latestTs = '', latestRow = null;
19214        allRows.forEach(function(r) {
19215          var p = r.dataset.project || ''; if (p) projects[p] = true;
19216          var ts = r.dataset.timestamp || '';
19217          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
19218        });
19219        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();}
19220        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>':'');}
19221        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
19222        if (latestRow) {
19223          setChipVal('agg-code', latestRow.dataset.code);
19224          setChipVal('agg-files', latestRow.dataset.files);
19225        }
19226      })();
19227
19228      // ── Branch filter population ──────────────────────────────────────────
19229      (function() {
19230        var branches = {};
19231        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
19232        var sel = document.getElementById('branch-filter');
19233        if (sel) Object.keys(branches).sort().forEach(function(b) {
19234          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
19235        });
19236      })();
19237
19238      // ── Filter ────────────────────────────────────────────────────────────
19239      function getFilteredRows() {
19240        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
19241        var branch = ((document.getElementById('branch-filter') || {}).value || '');
19242        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
19243          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
19244          if (branch && (r.dataset.branch || '') !== branch) return false;
19245          return true;
19246        });
19247      }
19248
19249      // ── Pagination ────────────────────────────────────────────────────────
19250      function renderPage() {
19251        var filtered = getFilteredRows();
19252        var total = filtered.length;
19253        var totalPages = Math.max(1, Math.ceil(total / perPage));
19254        currentPage = Math.min(currentPage, totalPages);
19255        var start = (currentPage - 1) * perPage;
19256        var end = Math.min(start + perPage, total);
19257        var shown = {};
19258        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
19259        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
19260          r.style.display = shown[r.dataset.run] ? '' : 'none';
19261        });
19262        var rl = document.getElementById('page-range-label');
19263        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
19264        var info = document.getElementById('pagination-info');
19265        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
19266        var btns = document.getElementById('pagination-btns');
19267        if (!btns) return;
19268        btns.innerHTML = '';
19269        function makeBtn(lbl, pg, active, disabled) {
19270          var b = document.createElement('button');
19271          b.className = 'pg-btn' + (active ? ' active' : '');
19272          b.textContent = lbl; b.disabled = disabled;
19273          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
19274          return b;
19275        }
19276        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
19277        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
19278        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
19279        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
19280      }
19281
19282      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
19283      window.applyFilters = function() { currentPage = 1; renderPage(); };
19284
19285      // ── Sorting ───────────────────────────────────────────────────────────
19286      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
19287      function doSort(col, type, order) {
19288        var tbody = document.getElementById('compare-tbody');
19289        if (!tbody) return;
19290        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19291        rows.sort(function(a, b) {
19292          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
19293          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
19294          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
19295          return va < vb ? 1 : va > vb ? -1 : 0;
19296        });
19297        rows.forEach(function(r) { tbody.appendChild(r); });
19298        currentPage = 1; renderPage();
19299      }
19300      sortHeaders.forEach(function(th) {
19301        th.addEventListener('click', function(e) {
19302          if (e.target.classList.contains('col-resize-handle')) return;
19303          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
19304          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
19305          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19306          th.classList.add('sort-' + sortOrder);
19307          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
19308          doSort(col, type, sortOrder);
19309        });
19310      });
19311
19312      // Apply default sort (timestamp desc) on initial load
19313      (function() {
19314        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
19315        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
19316      })();
19317
19318      // ── Column resize ─────────────────────────────────────────────────────
19319      (function() {
19320        var table = document.getElementById('compare-table');
19321        if (!table) return;
19322        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
19323        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
19324        ths.forEach(function(th, i) {
19325          var handle = th.querySelector('.col-resize-handle');
19326          if (!handle || !cols[i]) return;
19327          var startX, startW;
19328          handle.addEventListener('mousedown', function(e) {
19329            e.stopPropagation(); e.preventDefault();
19330            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
19331            handle.classList.add('dragging');
19332            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
19333            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
19334            document.addEventListener('mousemove', onMove);
19335            document.addEventListener('mouseup', onUp);
19336          });
19337        });
19338      })();
19339
19340      // ── Reset view ────────────────────────────────────────────────────────
19341      window.resetView = function() {
19342        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
19343        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
19344        sortCol = null; sortOrder = 'asc';
19345        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
19346        var tbody = document.getElementById('compare-tbody');
19347        if (tbody) {
19348          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
19349          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
19350          rows.forEach(function(r) { tbody.appendChild(r); });
19351        }
19352        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
19353        var table = document.getElementById('compare-table');
19354        currentPage = 1; renderPage();
19355        currentPage = 1; renderPage();
19356      };
19357
19358      renderPage();
19359
19360      // ── Row selection state ───────────────────────────────────────────────
19361      var selected = [];
19362      function updateCompareBtn() {
19363        var btn = document.getElementById('compare-btn');
19364        var cnt = document.getElementById('sel-count');
19365        if (!btn) return;
19366        btn.disabled = selected.length !== 2;
19367        if (cnt) cnt.textContent = selected.length + '/2';
19368      }
19369
19370      function toggleRow(row) {
19371        var vid = row.dataset.vid || row.dataset.run;
19372        var idx = selected.indexOf(vid);
19373        if (idx >= 0) {
19374          selected.splice(idx, 1);
19375          row.classList.remove('selected');
19376          var b = document.getElementById('badge-' + vid);
19377          if (b) b.textContent = '';
19378        } else {
19379          if (selected.length >= 2) return;
19380          selected.push(vid);
19381          row.classList.add('selected');
19382        }
19383        selected.forEach(function(v, i) {
19384          var b = document.getElementById('badge-' + v);
19385          if (b) b.textContent = i + 1;
19386        });
19387        updateCompareBtn();
19388        buildScopePanel();
19389      }
19390
19391      // ── Scope panel ───────────────────────────────────────────────────────
19392      var selectedScope = 'all';
19393
19394      function buildScopePanel() {
19395        var panel = document.getElementById('scope-panel');
19396        var opts = document.getElementById('scope-options');
19397        if (!panel || !opts) return;
19398        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19399
19400        // Collect union of submodules from both selected rows.
19401        var allSubs = {};
19402        selected.forEach(function(vid) {
19403          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
19404          if (!row) return;
19405          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
19406        });
19407        var subList = Object.keys(allSubs).sort();
19408        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
19409
19410        panel.classList.remove('hidden');
19411        opts.innerHTML = '';
19412
19413        function makeOption(value, label, title) {
19414          var div = document.createElement('div');
19415          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
19416          div.dataset.scopeValue = value;
19417          if (title) div.title = title;
19418          var radio = document.createElement('span');
19419          radio.className = 'scope-option-radio';
19420          var lbl = document.createElement('span');
19421          lbl.textContent = label;
19422          div.appendChild(radio);
19423          div.appendChild(lbl);
19424          div.addEventListener('click', function() {
19425            selectedScope = value;
19426            opts.querySelectorAll('.scope-option').forEach(function(o) {
19427              o.classList.toggle('selected', o.dataset.scopeValue === value);
19428            });
19429          });
19430          return div;
19431        }
19432
19433        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
19434        var sep = document.createElement('span');
19435        sep.className = 'scope-option-sep';
19436        opts.appendChild(sep);
19437        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
19438        subList.forEach(function(s) {
19439          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
19440        });
19441      }
19442
19443      function doCompare() {
19444        if (selected.length !== 2) return;
19445        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
19446        if (selectedScope === 'super') url += '&scope=super';
19447        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
19448        window.location.href = url;
19449      }
19450
19451      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
19452      var cbtn = document.getElementById('compare-btn');
19453      if (cbtn) cbtn.addEventListener('click', doCompare);
19454      var pfEl = document.getElementById('project-filter');
19455      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
19456      var bfEl = document.getElementById('branch-filter');
19457      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
19458      var rvBtn = document.getElementById('reset-view-btn');
19459      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
19460      var ppSel = document.getElementById('per-page-sel');
19461      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
19462
19463      var cmpTbody = document.getElementById('compare-tbody');
19464      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
19465        var row = e.target.closest('.compare-row');
19466        if (row) toggleRow(row);
19467      });
19468
19469      (function randomizeWatermarks() {
19470        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19471        if (!wms.length) return;
19472        var placed = [];
19473        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;}
19474        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];}
19475        var half=Math.floor(wms.length/2);
19476        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;});
19477      })();
19478
19479      (function spawnCodeParticles() {
19480        var container = document.getElementById('code-particles');
19481        if (!container) return;
19482        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'];
19483        for (var i = 0; i < 38; i++) {
19484          (function(idx) {
19485            var el = document.createElement('span');
19486            el.className = 'code-particle';
19487            el.textContent = snippets[idx % snippets.length];
19488            var left = Math.random() * 94 + 2;
19489            var top = Math.random() * 88 + 6;
19490            var dur = (Math.random() * 10 + 9).toFixed(1);
19491            var delay = (Math.random() * 18).toFixed(1);
19492            var rot = (Math.random() * 26 - 13).toFixed(1);
19493            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19494            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';
19495            container.appendChild(el);
19496          })(i);
19497        }
19498      })();
19499
19500      // ── Watched folder picker ─────────────────────────────────────────────
19501      (function() {
19502        var btn = document.getElementById('add-watched-btn');
19503        if (!btn) return;
19504        btn.addEventListener('click', function() {
19505          fetch('/pick-directory?kind=reports')
19506            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
19507            .then(function(data) {
19508              if (!data.cancelled && data.selected_path) {
19509                var form = document.createElement('form');
19510                form.method = 'POST';
19511                form.action = '/watched-dirs/add';
19512                var ri = document.createElement('input');
19513                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
19514                var fi = document.createElement('input');
19515                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
19516                form.appendChild(ri); form.appendChild(fi);
19517                document.body.appendChild(form);
19518                form.submit();
19519              }
19520            })
19521            .catch(function(e) { alert('Could not open folder picker: ' + e); });
19522        });
19523      })();
19524
19525      // ── Submodule chip truncation ─────────────────────────────────────────
19526      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
19527        var chips = cell.querySelectorAll('.submod-chip');
19528        var MAX = 4;
19529        if (chips.length <= MAX) return;
19530        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
19531        var badge = document.createElement('span');
19532        badge.className = 'submod-overflow-badge';
19533        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
19534        badge.textContent = '+' + (chips.length - MAX) + ' more';
19535        cell.appendChild(badge);
19536        cell.style.maxHeight = 'none';
19537      });
19538    })();
19539  </script>
19540  <script nonce="{{ csp_nonce }}">
19541  (function(){
19542    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'}];
19543    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);});}
19544    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19545    function init(){
19546      var btn=document.getElementById('settings-btn');if(!btn)return;
19547      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19548      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>';
19549      document.body.appendChild(m);
19550      var g=document.getElementById('scheme-grid');
19551      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);});
19552      var cl=document.getElementById('settings-close');
19553      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);
19554      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');});
19555      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19556      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19557    }
19558    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19559  }());
19560  </script>
19561  <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>
19562</body>
19563</html>
19564"##,
19565    ext = "html"
19566)]
19567struct CompareSelectTemplate {
19568    version: &'static str,
19569    entries: Vec<HistoryEntryRow>,
19570    total_scans: usize,
19571    watched_dirs: Vec<String>,
19572    csp_nonce: String,
19573    server_mode: bool,
19574}
19575
19576// ── CompareTemplate ────────────────────────────────────────────────────────────
19577
19578#[derive(Template)]
19579#[template(
19580    source = r##"
19581<!doctype html>
19582<html lang="en">
19583<head>
19584  <meta charset="utf-8">
19585  <meta name="viewport" content="width=device-width, initial-scale=1">
19586  <title>OxideSLOC | Scan Delta</title>
19587  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19588  <style nonce="{{ csp_nonce }}">
19589    :root {
19590      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
19591      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
19592      --nav:#283790; --nav-2:#013e6b;
19593      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
19594      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
19595      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
19596    }
19597    body.dark-theme {
19598      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
19599      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
19600    }
19601    *{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;}
19602    .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);}
19603    .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;}
19604    .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));}
19605    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19606    .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;}
19607    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
19608    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19609    @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; } }
19610    .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;}
19611    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19612    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19613    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19614    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19615    .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;}
19616    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19617    .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);}
19618    .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;}
19619    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19620    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19621    .settings-modal-body{padding:14px 16px 16px;}
19622    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19623    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19624    .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;}
19625    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19626    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19627    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19628    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19629    .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;}
19630    .tz-select:focus{border-color:var(--oxide);}
19631    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
19632    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
19633    .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;}
19634    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
19635    .hero-body{display:block;}
19636    .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;}
19637    .btn-back:hover{background:var(--line);}
19638    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
19639    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
19640    .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;}
19641    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
19642    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;}
19643    .muted{color:var(--muted);font-size:14px;}
19644    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
19645    .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;}
19646    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
19647    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
19648    .vpill-arrow{font-size:20px;color:var(--muted);}
19649    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
19650    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
19651    .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;}
19652    .delta-card.delta-card-wide{padding:22px 24px;}
19653    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
19654    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
19655    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
19656    .delta-card-from{font-size:15px;color:var(--muted);}
19657    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
19658    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
19659    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
19660    .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%;}
19661    .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;}
19662    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
19663    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
19664    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
19665    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
19666    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
19667    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
19668    .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;}
19669    .meta-card-commit:hover{color:var(--oxide);}
19670    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
19671    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
19672    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
19673    .meta-value{color:var(--text);font-size:13px;}
19674    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
19675    .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;}
19676    .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);}
19677    .delta-card:hover .dc-tip{display:block;}
19678    .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;}
19679    .export-btn:hover{background:var(--line);}
19680    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
19681    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
19682    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
19683    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
19684    .delta-card-change.zero{color:var(--muted);background:transparent;}
19685    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
19686    .delta-card-pct.pos{color:var(--pos);}
19687    .delta-card-pct.neg{color:var(--neg);}
19688    .delta-card-pct.zero{color:var(--muted);}
19689    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
19690    .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;}
19691    .insight-card.insight-flag{border-color:var(--oxide);}
19692    .insight-card:hover .dc-tip{display:block;}
19693    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
19694    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
19695    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
19696    .insight-label.flag{color:var(--oxide);}
19697    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
19698    .insight-val.pos{color:var(--pos);}
19699    .insight-val.neg{color:var(--neg);}
19700    .insight-val.high{color:#c0392a;}
19701    .insight-val.med{color:#926000;}
19702    .insight-val.low{color:var(--pos);}
19703    body.dark-theme .insight-val.high{color:#ff6b6b;}
19704    body.dark-theme .insight-val.med{color:#f0c060;}
19705    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
19706    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
19707    .fc-row{display:flex;align-items:center;gap:8px;}
19708    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
19709    .fc-label{color:var(--muted);}
19710    .fc-modified .fc-count{color:#926000;}
19711    .fc-added .fc-count{color:var(--pos);}
19712    .fc-removed .fc-count{color:var(--neg);}
19713    .fc-unchanged .fc-count{color:var(--muted);}
19714    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
19715    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
19716    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
19717    .chip.modified{background:#fff2d8;color:#926000;}
19718    .chip.added{background:#e8f5ed;color:#1a8f47;}
19719    .chip.removed{background:#fdeaea;color:#b33b3b;}
19720    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
19721    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
19722    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
19723    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
19724    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
19725    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
19726    .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;}
19727    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
19728    .tab-btn:hover:not(.active){background:var(--line);}
19729    .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;}
19730    .btn-reset:hover{background:var(--line);}
19731    .table-wrap{width:100%;overflow-x:auto;}
19732    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
19733    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;}
19734    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
19735    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
19736    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
19737    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
19738    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
19739    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
19740    tr:last-child td{border-bottom:none;}
19741    tr.row-added td{background:rgba(26,143,71,0.06);}
19742    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
19743    tr.row-modified td{background:rgba(146,96,0,0.05);}
19744    tr.row-unchanged td{opacity:.6;}
19745    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
19746    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
19747    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
19748    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
19749    .status-badge.modified{background:#fff2d8;color:#926000;}
19750    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
19751    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
19752    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
19753    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
19754    .delta-val{font-weight:700;}
19755    .delta-val.pos{color:var(--pos);}
19756    .delta-val.neg{color:var(--neg);}
19757    .delta-val.zero{color:var(--muted);}
19758    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
19759    .from-to strong{color:var(--text);}
19760    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19761    .site-footer a{color:var(--muted);}
19762    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
19763    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
19764    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19765    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19766    .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;}
19767    .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;}
19768    .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;}
19769    @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));}}
19770    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
19771    .path-link:hover{color:var(--oxide-2);}
19772    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
19773    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
19774    a.vpill-id:hover{color:var(--oxide);}
19775    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
19776    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
19777    .pagination-info{font-size:13px;color:var(--muted);}
19778    .pagination-btns{display:flex;gap:6px;}
19779    .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;}
19780    .pg-btn:hover:not(:disabled){background:var(--line);}
19781    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19782    .pg-btn:disabled{opacity:.35;cursor:default;}
19783    .per-page-label{font-size:13px;color:var(--muted);}
19784    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;}
19785    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19786    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
19787    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
19788    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
19789    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
19790    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
19791    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
19792    .tab-btn.tab-unchanged{color:var(--muted);}
19793    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
19794    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
19795    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
19796    .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;}
19797    .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;}
19798    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
19799    .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;}
19800    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
19801    .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;}
19802    .submod-scope-btn:hover{background:var(--line);}
19803    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
19804    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
19805    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
19806    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
19807    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
19808    body.dark-theme .ic-card{background:var(--surface-2);}
19809    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
19810    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
19811    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
19812    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
19813    #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;}
19814  </style>
19815</head>
19816<body>
19817  <div class="background-watermarks" aria-hidden="true">
19818    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19819    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19820    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19821    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19822    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19823    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19824  </div>
19825  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19826  <div class="top-nav">
19827    <div class="top-nav-inner">
19828      <a class="brand" href="/">
19829        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19830        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
19831      </a>
19832      <div class="nav-right">
19833        <a class="nav-pill" href="/">Home</a>
19834        <div class="nav-dropdown">
19835          <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>
19836          <div class="nav-dropdown-menu">
19837            <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>
19838          </div>
19839        </div>
19840        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19841        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19842        <div class="nav-dropdown">
19843          <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>
19844          <div class="nav-dropdown-menu">
19845            <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>
19846          </div>
19847        </div>
19848        <div class="server-status-wrap" id="server-status-wrap">
19849          <div class="nav-pill server-online-pill" id="server-status-pill">
19850            <span class="status-dot" id="status-dot"></span>
19851            <span id="server-status-label">Server</span>
19852            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19853          </div>
19854          <div class="server-status-tip">
19855            OxideSLOC is running — accessible on your network.
19856            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19857          </div>
19858        </div>
19859        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19860          <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>
19861        </button>
19862        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19863          <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>
19864          <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>
19865        </button>
19866      </div>
19867    </div>
19868  </div>
19869
19870  <div class="page">
19871    <section class="hero">
19872      <div class="hero-header">
19873        <div>
19874          <h1 class="delta-title">Scan Delta</h1>
19875          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
19876          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
19877            {% if let Some(sub) = active_submodule %}
19878            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
19879            {% else if super_scope_active %}
19880            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
19881            {% else %}
19882            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
19883            {% endif %}
19884            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
19885          </div>
19886        </div>
19887        <a class="btn-back" href="/compare-scans">
19888          <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>
19889          Compare Scans
19890        </a>
19891      </div>
19892      {% if has_any_submodule_data %}
19893      <div class="submod-scope-bar">
19894        <span class="submod-scope-label">
19895          <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>
19896          Scope:
19897        </span>
19898        <div class="submod-scope-divider"></div>
19899        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
19900           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
19901           title="All files — super-repo and all submodules combined">Full scan</a>
19902        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
19903           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
19904           title="Only files that are not part of any submodule">Super-repo only</a>
19905        {% for sub in submodule_options %}
19906        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
19907           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
19908           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
19909        {% endfor %}
19910      </div>
19911      {% endif %}
19912      <div class="hero-body">
19913      <div class="meta-strip">
19914        <div class="delta-card delta-card-meta">
19915          <div class="meta-card-header">
19916            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
19917            <div class="meta-card-project-col">
19918              <div class="meta-card-project">{{ project_name }}</div>
19919              {% if has_any_submodule_data %}
19920              {% if let Some(sub) = active_submodule %}
19921              <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>
19922              {% else if super_scope_active %}
19923              <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>
19924              {% else %}
19925              <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>
19926              {% endif %}
19927              {% endif %}
19928            </div>
19929          </div>
19930          {% if !baseline_git_commit.is_empty() %}
19931          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
19932          {% else %}
19933          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
19934          {% endif %}
19935          <div class="meta-card-rows">
19936            <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>
19937            <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>
19938            <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>
19939            <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>
19940            {% if let Some(tags) = baseline_git_tags %}
19941            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
19942            {% endif %}
19943          </div>
19944        </div>
19945        <div class="delta-card delta-card-meta">
19946          <div class="meta-card-header">
19947            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
19948            <div class="meta-card-project-col">
19949              <div class="meta-card-project">{{ project_name }}</div>
19950              {% if has_any_submodule_data %}
19951              {% if let Some(sub) = active_submodule %}
19952              <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>
19953              {% else if super_scope_active %}
19954              <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>
19955              {% else %}
19956              <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>
19957              {% endif %}
19958              {% endif %}
19959            </div>
19960          </div>
19961          {% if !current_git_commit.is_empty() %}
19962          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
19963          {% else %}
19964          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
19965          {% endif %}
19966          <div class="meta-card-rows">
19967            <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>
19968            <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>
19969            <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>
19970            <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>
19971            {% if let Some(tags) = current_git_tags %}
19972            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
19973            {% endif %}
19974          </div>
19975        </div>
19976      </div>
19977      <div class="delta-strip">
19978        <div class="delta-card">
19979          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
19980          <div class="delta-card-label">Code lines</div>
19981          <div class="delta-card-from">Before: {{ baseline_code }}</div>
19982          <div class="delta-card-to">{{ current_code }}</div>
19983          {% 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>
19984          {% 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>
19985          {% else %}<div class="delta-card-pct zero">±0%</div>
19986          {% endif %}
19987        </div>
19988        <div class="delta-card">
19989          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
19990          <div class="delta-card-label">Files analyzed</div>
19991          <div class="delta-card-from">Before: {{ baseline_files }}</div>
19992          <div class="delta-card-to">{{ current_files }}</div>
19993          {% 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>
19994          {% 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>
19995          {% else %}<div class="delta-card-pct zero">±0%</div>
19996          {% endif %}
19997        </div>
19998        <div class="delta-card">
19999          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
20000          <div class="delta-card-label">Comment lines</div>
20001          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
20002          <div class="delta-card-to">{{ current_comments }}</div>
20003          {% 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>
20004          {% 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>
20005          {% else %}<div class="delta-card-pct zero">±0%</div>
20006          {% endif %}
20007        </div>
20008        {{ coverage_delta_card|safe }}
20009        <div class="delta-card delta-card-wide">
20010          <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>
20011          <div class="delta-card-label">File changes</div>
20012          <div class="file-changes-grid">
20013            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
20014            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
20015            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
20016            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
20017          </div>
20018        </div>
20019      </div>
20020      <div class="insights-panel">
20021        <div class="insight-card">
20022          <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>
20023          <div class="insight-label">Lines Added</div>
20024          <div class="insight-val pos">+{{ code_lines_added }}</div>
20025          <div class="insight-sub">New or grown source lines</div>
20026        </div>
20027        <div class="insight-card">
20028          <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>
20029          <div class="insight-label">Lines Removed</div>
20030          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
20031          <div class="insight-sub">Deleted or shrunk source lines</div>
20032        </div>
20033        <div class="insight-card">
20034          <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>
20035          <div class="insight-label">Churn Rate</div>
20036          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
20037          <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>
20038        </div>
20039        {% if scope_flag %}
20040        <div class="insight-card insight-flag">
20041          <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>
20042          <div class="insight-label flag">Scope Signal</div>
20043          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
20044          <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>
20045        </div>
20046        {% endif %}
20047      </div>
20048      </div>
20049    </section>
20050
20051    <section class="panel" id="inline-charts-section">
20052      <h2>Scan Delta Charts</h2>
20053      <div class="ic-grid">
20054        <div class="ic-card">
20055          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
20056          <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>
20057          <div id="ic-c1"></div>
20058        </div>
20059        <div class="ic-card" id="ic-lang-card">
20060          <div class="ic-card-h2">Language Code Delta</div>
20061          <div id="ic-c3"></div>
20062        </div>
20063        <div class="ic-card">
20064          <div class="ic-card-h2">Delta by Metric</div>
20065          <div id="ic-c2"></div>
20066        </div>
20067        <div class="ic-card">
20068          <div class="ic-card-h2">File Change Distribution</div>
20069          <div id="ic-c4"></div>
20070        </div>
20071      </div>
20072    </section>
20073
20074    <section class="panel">
20075      <h2>File-level delta</h2>
20076      <div class="filter-tabs-row">
20077        <div class="filter-tabs">
20078          <button class="tab-btn tab-all active" data-filter="all">All</button>
20079          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
20080          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
20081          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
20082          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
20083        </div>
20084        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
20085          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
20086          <div class="export-group">
20087            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
20088            <button type="button" class="export-btn" id="delta-csv-btn">
20089              <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>
20090              CSV
20091            </button>
20092            <button type="button" class="export-btn" id="delta-xls-btn">
20093              <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>
20094              Excel
20095            </button>
20096            <button type="button" class="export-btn" id="delta-charts-btn">
20097              <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>
20098              Charts
20099            </button>
20100          </div>
20101        </div>
20102      </div>
20103
20104      <div class="table-wrap">
20105      <table id="delta-table">
20106        <colgroup>
20107          <col>
20108          <col>
20109          <col>
20110          <col>
20111          <col>
20112          <col>
20113          <col>
20114        </colgroup>
20115        <thead>
20116          <tr id="delta-thead">
20117            <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>
20118            <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>
20119            <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>
20120            <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>
20121            <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>
20122            <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>
20123            <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>
20124          </tr>
20125        </thead>
20126        <tbody id="delta-tbody">
20127          {% for row in file_rows %}
20128          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
20129              data-path="{{ row.relative_path }}"
20130              data-language="{{ row.language }}"
20131              data-baseline-code="{{ row.baseline_code }}"
20132              data-current-code="{{ row.current_code }}"
20133              data-code-delta="{{ row.code_delta_str }}"
20134              data-comment-delta="{{ row.comment_delta_str }}"
20135              data-total-delta="{{ row.total_delta_str }}"
20136              data-orig-idx="">
20137            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
20138            <td class="hide-sm">{{ row.language }}</td>
20139            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
20140            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
20141            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
20142            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
20143            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
20144          </tr>
20145          {% endfor %}
20146        </tbody>
20147      </table>
20148      </div>
20149      <div class="pagination">
20150        <span class="pagination-info" id="pg-info"></span>
20151        <div class="pagination-btns" id="pg-btns"></div>
20152        <div class="flex-row">
20153          <span class="per-page-label">Show</span>
20154          <select class="per-page" id="per-page-sel">
20155            <option value="10">10 per page</option>
20156            <option value="25" selected>25 per page</option>
20157            <option value="50">50 per page</option>
20158            <option value="100">100 per page</option>
20159          </select>
20160          <span class="per-page-label" id="pg-range-label"></span>
20161        </div>
20162      </div>
20163    </section>
20164  </div>
20165
20166  <div id="ic-tt"></div>
20167
20168  <footer class="site-footer">
20169    local code analysis - metrics, history and reports
20170    &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>
20171    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20172    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20173    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20174    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20175  </footer>
20176
20177  <script nonce="{{ csp_nonce }}">
20178    (function () {
20179      var storageKey = 'oxide-sloc-theme';
20180      var body = document.body;
20181      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20182      var toggle = document.getElementById('theme-toggle');
20183      if (toggle) toggle.addEventListener('click', function () {
20184        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20185        body.classList.toggle('dark-theme', next === 'dark');
20186        try { localStorage.setItem(storageKey, next); } catch(e) {}
20187      });
20188
20189      (function randomizeWatermarks() {
20190        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20191        if (!wms.length) return;
20192        var placed = [];
20193        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;}
20194        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];}
20195        var half=Math.floor(wms.length/2);
20196        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;});
20197      })();
20198
20199      (function spawnCodeParticles() {
20200        var container = document.getElementById('code-particles');
20201        if (!container) return;
20202        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'];
20203        for (var i = 0; i < 38; i++) {
20204          (function(idx) {
20205            var el = document.createElement('span');
20206            el.className = 'code-particle';
20207            el.textContent = snippets[idx % snippets.length];
20208            var left = Math.random() * 94 + 2;
20209            var top = Math.random() * 88 + 6;
20210            var dur = (Math.random() * 10 + 9).toFixed(1);
20211            var delay = (Math.random() * 18).toFixed(1);
20212            var rot = (Math.random() * 26 - 13).toFixed(1);
20213            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20214            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';
20215            container.appendChild(el);
20216          })(i);
20217        }
20218      })();
20219    })();
20220
20221    var activeStatusFilter = 'all';
20222    var deltaPerPage = 25, deltaCurrPage = 1;
20223
20224    function openFolder(path) {
20225      fetch('/open-path?path=' + encodeURIComponent(path))
20226        .then(function (r) { return r.json(); })
20227        .then(function (d) {
20228          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20229        })
20230        .catch(function () {});
20231    }
20232
20233    function getDeltaFilteredRows() {
20234      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
20235        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
20236      });
20237    }
20238
20239    function renderDeltaPage() {
20240      var filtered = getDeltaFilteredRows();
20241      var total = filtered.length;
20242      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
20243      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
20244      var start = (deltaCurrPage - 1) * deltaPerPage;
20245      var end = Math.min(start + deltaPerPage, total);
20246      var shownSet = {};
20247      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
20248      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
20249        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
20250      });
20251      var rl = document.getElementById('pg-range-label');
20252      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
20253      var info = document.getElementById('pg-info');
20254      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
20255      var btns = document.getElementById('pg-btns');
20256      if (!btns) return;
20257      btns.innerHTML = '';
20258      if (totalPages <= 1) return;
20259      function makeBtn(lbl, pg, active, disabled) {
20260        var b = document.createElement('button');
20261        b.className = 'pg-btn' + (active ? ' active' : '');
20262        b.textContent = lbl; b.disabled = disabled;
20263        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
20264        return b;
20265      }
20266      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
20267      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
20268      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
20269      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
20270    }
20271
20272    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
20273
20274    function filterRows(status, btn) {
20275      activeStatusFilter = status;
20276      deltaCurrPage = 1;
20277      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
20278        b.classList.remove('active');
20279      });
20280      if (btn) btn.classList.add('active');
20281      renderDeltaPage();
20282    }
20283
20284    // ── Sorting ──────────────────────────────────────────────────────────────
20285    var sortCol = null, sortOrder = 'asc';
20286    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
20287    (function() {
20288      var tbody = document.getElementById('delta-tbody');
20289      if (!tbody) return;
20290      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20291      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
20292    })();
20293
20294    function parseDeltaNum(str) {
20295      if (!str || str === '—') return 0;
20296      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
20297    }
20298
20299    sortHeaders.forEach(function(th) {
20300      th.addEventListener('click', function(e) {
20301        if (e.target.classList.contains('col-resize-handle')) return;
20302        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
20303        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
20304        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20305        th.classList.add('sort-' + sortOrder);
20306        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
20307        var tbody = document.getElementById('delta-tbody');
20308        if (!tbody) return;
20309        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20310        rows.sort(function(a, b) {
20311          var va, vb;
20312          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
20313          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
20314          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
20315          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
20316          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20317          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20318          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
20319          else { va = ''; vb = ''; }
20320          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
20321          return va < vb ? 1 : va > vb ? -1 : 0;
20322        });
20323        rows.forEach(function(r) { tbody.appendChild(r); });
20324        deltaCurrPage = 1;
20325        renderDeltaPage();
20326        var activeBtn = document.querySelector('.tab-btn.active');
20327        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20328        if (activeBtn) activeBtn.classList.add('active');
20329      });
20330    });
20331
20332    // ── Column resize ─────────────────────────────────────────────────────────
20333    (function() {
20334      var table = document.getElementById('delta-table');
20335      if (!table) return;
20336      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
20337      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
20338      ths.forEach(function(th, i) {
20339        var handle = th.querySelector('.col-resize-handle');
20340        if (!handle || !cols[i]) return;
20341        var startX, startW;
20342        handle.addEventListener('mousedown', function(e) {
20343          e.stopPropagation(); e.preventDefault();
20344          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
20345          handle.classList.add('dragging');
20346          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
20347          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
20348          document.addEventListener('mousemove', onMove);
20349          document.addEventListener('mouseup', onUp);
20350        });
20351      });
20352    })();
20353
20354    // ── Reset ─────────────────────────────────────────────────────────────────
20355    window.resetDeltaTable = function() {
20356      sortCol = null; sortOrder = 'asc';
20357      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
20358      var tbody = document.getElementById('delta-tbody');
20359      if (tbody) {
20360        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
20361        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
20362        rows.forEach(function(r) { tbody.appendChild(r); });
20363      }
20364      var table = document.getElementById('delta-table');
20365      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
20366      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
20367      activeStatusFilter = 'all';
20368      deltaCurrPage = 1;
20369      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
20370      var allBtn = document.querySelector('.tab-btn');
20371      if (allBtn) allBtn.classList.add('active');
20372      renderDeltaPage();
20373    };
20374
20375    renderDeltaPage();
20376
20377    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
20378    (function() {
20379      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
20380        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
20381      });
20382      var resetBtn = document.getElementById('delta-reset-btn');
20383      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
20384      var csvBtn = document.getElementById('delta-csv-btn');
20385      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
20386      var xlsBtn = document.getElementById('delta-xls-btn');
20387      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
20388      var chartsBtn = document.getElementById('delta-charts-btn');
20389      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
20390      var ppSel = document.getElementById('per-page-sel');
20391      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
20392      var pathLink = document.getElementById('project-path-link');
20393      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
20394    })();
20395
20396    // ── Export helpers ────────────────────────────────────────────────────────
20397    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
20398    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
20399    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);}
20400    function slocMakeXlsx(fname,sd,dr){
20401      var enc=new TextEncoder();
20402      // CRC-32 table
20403      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;}
20404      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;}
20405      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
20406      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
20407      // Shared string table
20408      var ss=[],si={};
20409      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
20410      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20411      // Worksheet builder — each WS() call gets its own row counter R
20412      function WS(){
20413        var R=0,buf=[];
20414        function cl(c){return String.fromCharCode(65+c);}
20415        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
20416          '<v>'+S(v)+'</v></c>';}
20417        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
20418          (st?' s="'+st+'"':'')+'>'+
20419          '<v>'+(+v)+'</v></c>';}
20420        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
20421        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20422          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
20423          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
20424          '<sheetFormatPr defaultRowHeight="15"/>'+
20425          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
20426        return{sc:sc,nc:nc,row:row,xml:xml};
20427      }
20428      // Language breakdown
20429      var lm={};
20430      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;});
20431      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
20432      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
20433      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
20434      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
20435      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20436      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20437      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):'';}
20438      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
20439      // Summary sheet
20440      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
20441      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
20442      r1(s1(0,proj,2));
20443      r1(s1(0,sd.bts+' → '+sd.cts,2));
20444      r1('');
20445      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
20446      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))));
20447      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))));
20448      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))));
20449      r1('');
20450      r1(s1(0,'FILE CHANGES',8));
20451      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
20452      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
20453      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
20454      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
20455      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
20456      if(langs.length){
20457        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
20458        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
20459        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)));});
20460      }
20461      r1('');r1(s1(0,'SCAN METADATA',8));
20462      r1(s1(1,_blabel)+s1(2,_clabel));
20463      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
20464      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
20465      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"/>');
20466      // File Delta sheet
20467      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
20468      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));
20469      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)));});
20470      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
20471      // Shared strings XML
20472      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
20473        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
20474        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
20475      // XLSX file map
20476      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
20477      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>',
20478        '_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>',
20479        '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>',
20480        '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>',
20481        '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>',
20482        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
20483      // ZIP packer — STORED (no compression), compatible with all XLSX readers
20484      var zparts=[],zcds=[],zoff=0,znf=0;
20485      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
20486       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
20487      ].forEach(function(name){
20488        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
20489        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]);
20490        var entry=new Uint8Array(lha.length+nb.length+sz);
20491        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
20492        zparts.push(entry);
20493        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));
20494        var cde=new Uint8Array(cda.length+nb.length);
20495        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
20496        zcds.push(cde);zoff+=entry.length;znf++;
20497      });
20498      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
20499      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]);
20500      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
20501      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
20502      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
20503      zout.set(new Uint8Array(ea),zpos);
20504      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
20505      var xurl=URL.createObjectURL(xblob);
20506      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
20507      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
20508      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
20509    }
20510    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;');}
20511    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
20512    function getExportFilename(ext){return _exportBase+'.'+ext;}
20513
20514    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 }}'};
20515    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;}
20516    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
20517    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
20518    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
20519    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
20520    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):'';}
20521    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
20522    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)]];}
20523    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
20524    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;}
20525    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
20526    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
20527
20528    // ── Chart HTML report ─────────────────────────────────────────────────────
20529    function slocChartReport(fname, sd, dr) {
20530      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
20531      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20532      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20533      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();}
20534      function px(n){return Math.round(n);}
20535      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
20536      // Language map
20537      var lm={};
20538      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;});
20539      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20540
20541      // Builds onmouse* attrs for interactive tooltip on each SVG element
20542      function barTT(label,val){
20543        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
20544      }
20545
20546      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
20547      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'}];
20548      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
20549      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
20550      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
20551      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20552      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"/>';}
20553      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
20554      c1mets.forEach(function(m,i){
20555        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
20556        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
20557        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>';
20558        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))+'/>';
20559        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>';
20560        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))+'/>';
20561        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>';
20562        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>';
20563        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>';
20564      });
20565      c1+='</svg>';
20566
20567      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
20568      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'}];
20569      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
20570      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
20571      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
20572      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20573      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20574      mets.forEach(function(m,i){
20575        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
20576        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
20577        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
20578        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>';
20579        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
20580        if(bw>=52){
20581          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>';
20582        }else{
20583          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
20584          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>';
20585        }
20586      });
20587      c2+='</svg>';
20588
20589      // ── Chart 3: Language Code Delta ─────────────────────────────────────
20590      var c3='';
20591      if(langs.length){
20592        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
20593        var C3W=550,c3LW=124,c3FW=52;
20594        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
20595        var L3rH=30,C3H=langs.length*L3rH+20;
20596        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20597        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20598        langs.forEach(function(l,i){
20599          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
20600          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
20601          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
20602          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
20603          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':''))+'/>';
20604          if(bw>=48){
20605            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>';
20606          }else{
20607            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
20608            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>';
20609          }
20610          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>';
20611        });
20612        c3+='</svg>';
20613      }
20614
20615      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
20616      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;});
20617      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
20618      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
20619      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20620      var ang=-Math.PI/2;
20621      segs.forEach(function(s){
20622        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
20623        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
20624        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
20625        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
20626        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
20627        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)+'%')+'/>';
20628        ang+=sw;
20629      });
20630      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>';
20631      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
20632      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>';});
20633      c4+='</svg>';
20634
20635      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
20636      var ttJs='var tt=document.getElementById("ox-tt");'+
20637        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
20638        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
20639        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
20640        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
20641        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
20642        'function oxHT(){tt.style.display="none";}';
20643
20644      // body max-width keeps charts from inflating beyond design dimensions on
20645      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
20646      // each chart's height blows up proportionally, breaking the one-page layout.
20647      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;}'+
20648        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
20649        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
20650        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
20651        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
20652        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
20653        'svg{display:block;}'+
20654        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
20655        '#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;}'+
20656        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
20657      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
20658        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
20659        '<div id="ox-tt"><\/div>'+
20660        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
20661        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
20662        '<div class="two-col">'+
20663        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
20664        '<div class="leg">'+
20665        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
20666        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
20667        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
20668        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
20669        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
20670        '<\/div>'+
20671        '<div class="two-col">'+
20672        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
20673        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
20674        '<\/div>'+
20675        '<script>'+ttJs+'<\/script>'+
20676        '<\/body><\/html>';
20677      slocDownload(html, fname, 'text/html;charset=utf-8;');
20678    }
20679    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
20680    // ── Inline delta charts ────────────────────────────────────────────────────
20681    var _icTT=document.getElementById('ic-tt');
20682    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
20683    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';};
20684    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
20685    (function(){
20686      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
20687      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
20688      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();}
20689      function px(n){return Math.round(n);}
20690      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
20691      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
20692      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);});}
20693      var dr=getDeltaExportRows(),sd=_sd,lm={};
20694      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;});
20695      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
20696      // Chart 1: Baseline vs Current grouped bars
20697      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'}];
20698      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
20699      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;
20700      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20701      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"/>';}
20702      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
20703      c1mets.forEach(function(m,i){
20704        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
20705        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
20706        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>';
20707        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"/>';
20708        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>';
20709        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"/>';
20710        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>';
20711        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>';
20712        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>';
20713      });
20714      c1+='</svg>';
20715      // Chart 2: Delta by Metric
20716      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'}];
20717      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
20718      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;
20719      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20720      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20721      mets.forEach(function(m,i){
20722        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);
20723        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>';
20724        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
20725        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>';}
20726        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>';}
20727      });
20728      c2+='</svg>';
20729      // Chart 3: Language Code Delta
20730      var c3='';
20731      if(langs.length){
20732        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
20733        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;
20734        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
20735        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
20736        langs.forEach(function(l,i){
20737          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);
20738          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
20739          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"/>';
20740          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>';}
20741          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>';}
20742          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>';
20743        });
20744        c3+='</svg>';
20745      }
20746      // Chart 4: File Change Donut
20747      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;});
20748      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
20749      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;
20750      if(segs.length===1){
20751        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
20752        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
20753        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
20754      } else {
20755        segs.forEach(function(s){
20756          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
20757          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);
20758          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);
20759          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"/>';
20760          ang+=sw;
20761        });
20762      }
20763      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>';
20764      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
20765      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>';});
20766      c4+='</svg>';
20767      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
20768      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
20769      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);}
20770      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
20771      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
20772      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='  /'+el.textContent.replace(/\s+/g,'');});
20773    })();
20774  </script>
20775  <script nonce="{{ csp_nonce }}">
20776  (function(){
20777    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'}];
20778    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);});}
20779    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20780    function init(){
20781      var btn=document.getElementById('settings-btn');if(!btn)return;
20782      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20783      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>';
20784      document.body.appendChild(m);
20785      var g=document.getElementById('scheme-grid');
20786      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);});
20787      var cl=document.getElementById('settings-close');
20788      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);
20789      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');});
20790      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20791      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20792    }
20793    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20794  }());
20795  </script>
20796  <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>
20797</body>
20798</html>
20799"##,
20800    ext = "html"
20801)]
20802// Template structs need many bool fields to pass Askama rendering flags.
20803#[allow(clippy::struct_excessive_bools)]
20804struct CompareTemplate {
20805    version: &'static str,
20806    project_label: String,
20807    baseline_git_commit: String,
20808    current_git_commit: String,
20809    baseline_run_id: String,
20810    current_run_id: String,
20811    baseline_run_id_short: String,
20812    current_run_id_short: String,
20813    baseline_timestamp: String,
20814    baseline_timestamp_utc_ms: i64,
20815    current_timestamp: String,
20816    current_timestamp_utc_ms: i64,
20817    project_path: String,
20818    baseline_code: u64,
20819    current_code: u64,
20820    code_lines_delta_str: String,
20821    code_lines_delta_class: String,
20822    baseline_files: u64,
20823    current_files: u64,
20824    files_analyzed_delta_str: String,
20825    files_analyzed_delta_class: String,
20826    baseline_comments: u64,
20827    current_comments: u64,
20828    comment_lines_delta_str: String,
20829    comment_lines_delta_class: String,
20830    code_lines_pct_str: String,
20831    files_analyzed_pct_str: String,
20832    comment_lines_pct_str: String,
20833    code_lines_added: i64,
20834    code_lines_removed: i64,
20835    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
20836    new_scope: bool,
20837    churn_rate_str: String,
20838    churn_rate_class: String,
20839    scope_flag: bool,
20840    files_added: usize,
20841    files_removed: usize,
20842    files_modified: usize,
20843    files_unchanged: usize,
20844    file_rows: Vec<CompareFileDeltaRow>,
20845    baseline_git_author: Option<String>,
20846    current_git_author: Option<String>,
20847    baseline_git_branch: String,
20848    current_git_branch: String,
20849    baseline_git_tags: Option<String>,
20850    current_git_tags: Option<String>,
20851    baseline_git_commit_date: Option<String>,
20852    current_git_commit_date: Option<String>,
20853    project_name: String,
20854    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
20855    submodule_options: Vec<String>,
20856    /// True when either run has submodule data — controls whether the scope bar is shown.
20857    has_any_submodule_data: bool,
20858    /// The submodule currently being compared, if the `sub` query param was provided.
20859    active_submodule: Option<String>,
20860    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
20861    super_scope_active: bool,
20862    csp_nonce: String,
20863    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
20864    coverage_delta_card: String,
20865}
20866
20867// ── LoginTemplate ──────────────────────────────────────────────────────────────
20868
20869#[derive(Template)]
20870#[template(
20871    source = r##"
20872<!doctype html>
20873<html lang="en">
20874<head>
20875  <meta charset="utf-8">
20876  <meta name="viewport" content="width=device-width, initial-scale=1">
20877  <title>OxideSLOC | Sign In</title>
20878  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20879  <style nonce="{{ csp_nonce }}">
20880    :root {
20881      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
20882      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
20883      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
20884      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
20885    }
20886    *{box-sizing:border-box;}
20887    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);}
20888    .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);}
20889    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
20890    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
20891    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
20892    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20893    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20894    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20895    .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;}
20896    @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));}}
20897    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
20898    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
20899    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
20900    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
20901    .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;}
20902    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
20903    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;}
20904    input[type=password]:focus{border-color:var(--oxide);}
20905    .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;}
20906    .btn:hover{opacity:.88;}
20907    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
20908    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
20909  </style>
20910</head>
20911<body>
20912  <div class="background-watermarks" aria-hidden="true">
20913    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20914    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20915    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20916    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20917    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20918    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20919    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20920  </div>
20921  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20922<nav class="top-nav">
20923  <a class="brand" href="/">
20924    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
20925    <span class="brand-title">OxideSLOC</span>
20926  </a>
20927</nav>
20928<main class="page">
20929  <div class="card">
20930    <h1>Sign In</h1>
20931    <p class="subtitle">Enter the API key printed when the server started.</p>
20932    {% if has_error %}
20933    <div class="error">Incorrect API key — please try again.</div>
20934    {% endif %}
20935    <form method="POST" action="/auth/login">
20936      <input type="hidden" name="next" value="{{ next_url|e }}">
20937      <label for="key">API Key</label>
20938      <input id="key" type="password" name="key" autocomplete="current-password"
20939             placeholder="Paste your API key here" autofocus>
20940      <button type="submit" class="btn">Sign In</button>
20941    </form>
20942    <p class="hint">
20943      The API key was printed in the terminal when the server started.<br>
20944      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
20945      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
20946    </p>
20947  </div>
20948</main>
20949<script nonce="{{ csp_nonce }}">
20950(function() {
20951  (function randomizeWatermarks() {
20952    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20953    if (!wms.length) return;
20954    var placed = [];
20955    function tooClose(top, left) {
20956      for (var i = 0; i < placed.length; i++) {
20957        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
20958        if (dt < 16 && dl < 12) return true;
20959      }
20960      return false;
20961    }
20962    function pick(leftBand) {
20963      for (var attempt = 0; attempt < 50; attempt++) {
20964        var top = Math.random() * 88 + 2;
20965        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20966        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
20967      }
20968      var top = Math.random() * 88 + 2;
20969      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20970      placed.push([top, left]); return [top, left];
20971    }
20972    var half = Math.floor(wms.length / 2);
20973    wms.forEach(function (img, i) {
20974      var pos = pick(i < half);
20975      var size = Math.floor(Math.random() * 100 + 120);
20976      var rot = (Math.random() * 360).toFixed(1);
20977      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
20978      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;
20979    });
20980  })();
20981  (function spawnCodeParticles() {
20982    var container = document.getElementById('code-particles');
20983    if (!container) return;
20984    var snippets = [
20985      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
20986      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
20987      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
20988      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
20989      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
20990    ];
20991    var count = 38;
20992    for (var i = 0; i < count; i++) {
20993      (function(idx) {
20994        var el = document.createElement('span');
20995        el.className = 'code-particle';
20996        el.textContent = snippets[idx % snippets.length];
20997        var left = Math.random() * 94 + 2;
20998        var top = Math.random() * 88 + 6;
20999        var dur = (Math.random() * 10 + 9).toFixed(1);
21000        var delay = (Math.random() * 18).toFixed(1);
21001        var rot = (Math.random() * 26 - 13).toFixed(1);
21002        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21003        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
21004        container.appendChild(el);
21005      })(i);
21006    }
21007  })();
21008})();
21009</script>
21010</body>
21011</html>
21012"##,
21013    ext = "html"
21014)]
21015pub(crate) struct LoginTemplate {
21016    pub(crate) csp_nonce: String,
21017    pub(crate) has_error: bool,
21018    pub(crate) next_url: String,
21019    pub(crate) lockout_threshold: u32,
21020}
21021
21022// ── REST API reference page ────────────────────────────────────────────────────
21023
21024#[derive(Template)]
21025#[template(
21026    source = r##"
21027<!doctype html>
21028<html lang="en">
21029<head>
21030  <meta charset="utf-8">
21031  <meta name="viewport" content="width=device-width, initial-scale=1">
21032  <title>OxideSLOC — REST API Reference</title>
21033  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21034  <style nonce="{{ csp_nonce }}">
21035    :root {
21036      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21037      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21038      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21039      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21040      --success:#16a34a;
21041    }
21042    body.dark-theme {
21043      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21044      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21045    }
21046    *{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;}
21047    .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);}
21048    .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;}
21049    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
21050    .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));}
21051    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21052    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21053    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
21054    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
21055    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21056    @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; } }
21057    .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;}
21058    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
21059    .nav-pill.active{background:rgba(255,255,255,0.22);}
21060    .nav-dropdown{position:relative;display:inline-flex;}
21061    .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;}
21062    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
21063    .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;}
21064    .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;}
21065    .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);}
21066    .nav-dropdown-menu a:last-child{border-bottom:none;}
21067    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
21068    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
21069    .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;}
21070    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21071    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21072    .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;}
21073    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21074    .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);}
21075    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
21076    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
21077    .settings-modal-body{padding:14px 16px 16px;}
21078    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21079    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21080    .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;}
21081    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21082    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21083    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21084    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21085    .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;}
21086    .tz-select:focus{border-color:var(--oxide);}
21087    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21088    .page-header{margin-bottom:28px;}
21089    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
21090    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
21091    .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;}
21092    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
21093    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
21094    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
21095    .callout strong{font-weight:800;}
21096    .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;}
21097    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
21098    .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;}
21099    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
21100    .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;}
21101    body.dark-theme .base-url-value{color:var(--accent);}
21102    .section{margin-bottom:36px;}
21103    .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);}
21104    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
21105    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
21106    .ep-header:hover{background:var(--surface-2);}
21107    .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;}
21108    .method.get{background:#dcfce7;color:#166534;}
21109    .method.post{background:#dbeafe;color:#1e40af;}
21110    .method.delete{background:#fee2e2;color:#991b1b;}
21111    body.dark-theme .method.get{background:#14532d;color:#86efac;}
21112    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
21113    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
21114    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
21115    .ep-path .param{color:var(--oxide-2);}
21116    body.dark-theme .ep-path .param{color:var(--oxide);}
21117    .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;}
21118    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
21119    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
21120    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
21121    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
21122    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
21123    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
21124    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
21125    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
21126    .ep-card.open .chevron{transform:rotate(180deg);}
21127    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
21128    .ep-card.open .ep-body{display:block;}
21129    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
21130    .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;}
21131    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
21132    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
21133    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21134    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
21135    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);}
21136    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
21137    table.params tr:last-child td{border-bottom:none;}
21138    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
21139    .pt-type{color:var(--muted-2);font-size:12px;}
21140    .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;}
21141    .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;}
21142    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
21143    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
21144    details.schema{margin-bottom:14px;}
21145    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;}
21146    details.schema summary:hover{color:var(--text);}
21147    .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;}
21148    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
21149    .curl-wrap{position:relative;}
21150    .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;}
21151    .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;}
21152    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
21153    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
21154    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
21155    .webhook-note a{color:var(--accent-2);text-decoration:none;}
21156    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21157    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21158    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21159    .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;}
21160    @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));}}
21161    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21162    .site-footer a{color:var(--muted);}
21163  </style>
21164</head>
21165<body>
21166  <div class="background-watermarks" aria-hidden="true">
21167    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21168    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21169    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21170    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21171    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21172    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21173    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21174  </div>
21175  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21176  <div class="top-nav">
21177    <div class="top-nav-inner">
21178      <a class="brand" href="/">
21179        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21180        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
21181      </a>
21182      <div class="nav-right">
21183        <a class="nav-pill" href="/">Home</a>
21184        <div class="nav-dropdown">
21185          <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>
21186          <div class="nav-dropdown-menu">
21187            <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>
21188          </div>
21189        </div>
21190        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21191        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21192        <div class="nav-dropdown">
21193          <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>
21194          <div class="nav-dropdown-menu">
21195            <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>
21196          </div>
21197        </div>
21198        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21199          <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>
21200        </button>
21201        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21202          <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>
21203          <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>
21204        </button>
21205      </div>
21206    </div>
21207  </div>
21208
21209  <div class="page">
21210    <div class="page-header">
21211      <h1 class="page-title">REST API Reference</h1>
21212      <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>
21213    </div>
21214
21215    {% if has_api_key %}
21216    <div class="callout key-set">
21217      <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>
21218      <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>
21219    </div>
21220    {% else %}
21221    <div class="callout no-key">
21222      <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>
21223      <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>
21224    </div>
21225    {% endif %}
21226
21227    <div class="base-url-bar">
21228      <span class="base-url-label">Base URL</span>
21229      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
21230    </div>
21231
21232    <!-- Health -->
21233    <div class="section">
21234      <h2 class="section-title">Health &amp; Status</h2>
21235      <div class="ep-card">
21236        <div class="ep-header">
21237          <span class="method get">GET</span>
21238          <span class="ep-path">/healthz</span>
21239          <span class="auth-badge public">Public</span>
21240          <span class="ep-desc">Server liveness check</span>
21241          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21242        </div>
21243        <div class="ep-body">
21244          <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>
21245          <p class="params-heading">Response</p>
21246          <div class="schema-block">200 OK
21247Content-Type: text/plain
21248
21249ok</div>
21250          <p class="curl-heading">Example</p>
21251          <div class="curl-wrap">
21252            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
21253            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
21254          </div>
21255        </div>
21256      </div>
21257    </div>
21258
21259    <!-- Badges -->
21260    <div class="section">
21261      <h2 class="section-title">Badges</h2>
21262      <div class="ep-card">
21263        <div class="ep-header">
21264          <span class="method get">GET</span>
21265          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
21266          <span class="auth-badge public">Public</span>
21267          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
21268          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21269        </div>
21270        <div class="ep-body">
21271          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
21272          <p class="params-heading">Path Parameters</p>
21273          <table class="params">
21274            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21275            <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>
21276          </table>
21277          <p class="curl-heading">Example</p>
21278          <div class="curl-wrap">
21279            <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>
21280            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
21281          </div>
21282        </div>
21283      </div>
21284    </div>
21285
21286    <!-- Metrics -->
21287    <div class="section">
21288      <h2 class="section-title">Metrics</h2>
21289
21290      <div class="ep-card">
21291        <div class="ep-header">
21292          <span class="method get">GET</span>
21293          <span class="ep-path">/api/metrics/latest</span>
21294          <span class="auth-badge protected">Protected</span>
21295          <span class="ep-desc">Latest scan metrics (JSON)</span>
21296          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21297        </div>
21298        <div class="ep-body">
21299          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
21300          <details class="schema"><summary>Response schema</summary>
21301<div class="schema-block">{
21302  "run_id":    string,        // UUID
21303  "timestamp": string,        // ISO-8601 UTC
21304  "project":   string,        // scanned root path
21305  "summary": {
21306    "files_analyzed":       number,
21307    "files_skipped":        number,
21308    "code_lines":           number,
21309    "comment_lines":        number,
21310    "blank_lines":          number,
21311    "total_physical_lines": number,
21312    "functions":            number,
21313    "classes":              number,
21314    "variables":            number,
21315    "imports":              number
21316  },
21317  "languages": [
21318    { "name": string, "files": number, "code_lines": number,
21319      "comment_lines": number, "blank_lines": number,
21320      "functions": number, "classes": number,
21321      "variables": number, "imports": number }
21322  ]
21323}</div></details>
21324          <p class="curl-heading">Example</p>
21325          <div class="curl-wrap">
21326            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21327  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
21328            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
21329          </div>
21330        </div>
21331      </div>
21332
21333      <div class="ep-card">
21334        <div class="ep-header">
21335          <span class="method get">GET</span>
21336          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
21337          <span class="auth-badge protected">Protected</span>
21338          <span class="ep-desc">Metrics for a specific run</span>
21339          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21340        </div>
21341        <div class="ep-body">
21342          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
21343          <p class="params-heading">Path Parameters</p>
21344          <table class="params">
21345            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21346            <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>
21347          </table>
21348          <p class="curl-heading">Example</p>
21349          <div class="curl-wrap">
21350            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21351  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
21352            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
21353          </div>
21354        </div>
21355      </div>
21356
21357      <div class="ep-card">
21358        <div class="ep-header">
21359          <span class="method get">GET</span>
21360          <span class="ep-path">/api/metrics/history</span>
21361          <span class="auth-badge protected">Protected</span>
21362          <span class="ep-desc">Paginated scan history</span>
21363          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21364        </div>
21365        <div class="ep-body">
21366          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
21367          <p class="params-heading">Query Parameters</p>
21368          <table class="params">
21369            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21370            <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>
21371            <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>
21372          </table>
21373          <details class="schema"><summary>Response schema</summary>
21374<div class="schema-block">[{
21375  "run_id":         string,
21376  "timestamp":      string,   // ISO-8601 UTC
21377  "commit":         string | null,
21378  "branch":         string | null,
21379  "tags":           string[],
21380  "code_lines":     number,
21381  "comment_lines":  number,
21382  "blank_lines":    number,
21383  "physical_lines": number,
21384  "files_analyzed": number,
21385  "project_label":  string,
21386  "html_url":       string | null
21387}]</div></details>
21388          <p class="curl-heading">Example</p>
21389          <div class="curl-wrap">
21390            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21391  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
21392            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
21393          </div>
21394        </div>
21395      </div>
21396
21397      <div class="ep-card">
21398        <div class="ep-header">
21399          <span class="method get">GET</span>
21400          <span class="ep-path">/api/project-history</span>
21401          <span class="auth-badge protected">Protected</span>
21402          <span class="ep-desc">Project-level scan summary</span>
21403          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21404        </div>
21405        <div class="ep-body">
21406          <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>
21407          <p class="params-heading">Query Parameters</p>
21408          <table class="params">
21409            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21410            <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>
21411          </table>
21412          <details class="schema"><summary>Response schema</summary>
21413<div class="schema-block">{
21414  "scan_count":           number,
21415  "last_scan_id":         string | null,
21416  "last_scan_timestamp":  string | null,  // ISO-8601
21417  "last_scan_code_lines": number | null,
21418  "last_git_branch":      string | null,
21419  "last_git_commit":      string | null
21420}</div></details>
21421          <p class="curl-heading">Example</p>
21422          <div class="curl-wrap">
21423            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21424  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
21425            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
21426          </div>
21427        </div>
21428      </div>
21429
21430      <div class="ep-card">
21431        <div class="ep-header">
21432          <span class="method get">GET</span>
21433          <span class="ep-path">/api/metrics/submodules</span>
21434          <span class="auth-badge protected">Protected</span>
21435          <span class="ep-desc">List known git submodules across scans</span>
21436          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21437        </div>
21438        <div class="ep-body">
21439          <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>
21440          <p class="params-heading">Query Parameters</p>
21441          <table class="params">
21442            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21443            <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>
21444          </table>
21445          <details class="schema"><summary>Response schema</summary>
21446<div class="schema-block">[{
21447  "name":          string,  // submodule name
21448  "relative_path": string   // path relative to the project root
21449}]</div></details>
21450          <p class="curl-heading">Example</p>
21451          <div class="curl-wrap">
21452            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21453  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
21454            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
21455          </div>
21456        </div>
21457      </div>
21458    </div>
21459
21460    <!-- Async Run Status -->
21461    <div class="section">
21462      <h2 class="section-title">Async Run Status</h2>
21463
21464      <div class="ep-card">
21465        <div class="ep-header">
21466          <span class="method get">GET</span>
21467          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
21468          <span class="auth-badge protected">Protected</span>
21469          <span class="ep-desc">Poll scan completion</span>
21470          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21471        </div>
21472        <div class="ep-body">
21473          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
21474          <details class="schema"><summary>Response schema</summary>
21475<div class="schema-block">// Running
21476{ "state": "running",  "elapsed_secs": number }
21477
21478// Complete
21479{ "state": "complete", "run_id": string }
21480
21481// Failed
21482{ "state": "failed",   "message": string }</div></details>
21483          <p class="curl-heading">Example</p>
21484          <div class="curl-wrap">
21485            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21486  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
21487            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
21488          </div>
21489        </div>
21490      </div>
21491
21492      <div class="ep-card">
21493        <div class="ep-header">
21494          <span class="method get">GET</span>
21495          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
21496          <span class="auth-badge protected">Protected</span>
21497          <span class="ep-desc">Poll PDF generation readiness</span>
21498          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21499        </div>
21500        <div class="ep-body">
21501          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
21502          <details class="schema"><summary>Response schema</summary>
21503<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
21504          <p class="curl-heading">Example</p>
21505          <div class="curl-wrap">
21506            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21507  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
21508            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
21509          </div>
21510        </div>
21511      </div>
21512
21513      <div class="ep-card">
21514        <div class="ep-header">
21515          <span class="method post">POST</span>
21516          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
21517          <span class="auth-badge protected">Protected</span>
21518          <span class="ep-desc">Cancel a running scan</span>
21519          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21520        </div>
21521        <div class="ep-body">
21522          <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>
21523          <p class="curl-heading">Example</p>
21524          <div class="curl-wrap">
21525            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
21526  -H "Authorization: Bearer $SLOC_API_KEY" \
21527  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
21528            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
21529          </div>
21530        </div>
21531      </div>
21532    </div>
21533
21534    <!-- Scan Profiles -->
21535    <div class="section">
21536      <h2 class="section-title">Scan Profiles</h2>
21537
21538      <div class="ep-card">
21539        <div class="ep-header">
21540          <span class="method get">GET</span>
21541          <span class="ep-path">/api/scan-profiles</span>
21542          <span class="auth-badge protected">Protected</span>
21543          <span class="ep-desc">List saved scan profiles</span>
21544          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21545        </div>
21546        <div class="ep-body">
21547          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
21548          <details class="schema"><summary>Response schema</summary>
21549<div class="schema-block">{
21550  "profiles": [{
21551    "id":         string,   // UUID
21552    "name":       string,
21553    "created_at": string,   // ISO-8601
21554    "params":     object
21555  }]
21556}</div></details>
21557          <p class="curl-heading">Example</p>
21558          <div class="curl-wrap">
21559            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21560  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
21561            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
21562          </div>
21563        </div>
21564      </div>
21565
21566      <div class="ep-card">
21567        <div class="ep-header">
21568          <span class="method post">POST</span>
21569          <span class="ep-path">/api/scan-profiles</span>
21570          <span class="auth-badge protected">Protected</span>
21571          <span class="ep-desc">Save a scan profile</span>
21572          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21573        </div>
21574        <div class="ep-body">
21575          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
21576          <p class="params-heading">Request Body (application/json)</p>
21577          <table class="params">
21578            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
21579            <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>
21580            <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>
21581          </table>
21582          <details class="schema"><summary>Response schema</summary>
21583<div class="schema-block">{ "ok": true }</div></details>
21584          <p class="curl-heading">Example</p>
21585          <div class="curl-wrap">
21586            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
21587  -H "Authorization: Bearer $SLOC_API_KEY" \
21588  -H "Content-Type: application/json" \
21589  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
21590  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
21591            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
21592          </div>
21593        </div>
21594      </div>
21595
21596      <div class="ep-card">
21597        <div class="ep-header">
21598          <span class="method delete">DELETE</span>
21599          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
21600          <span class="auth-badge protected">Protected</span>
21601          <span class="ep-desc">Delete a scan profile</span>
21602          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21603        </div>
21604        <div class="ep-body">
21605          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
21606          <p class="params-heading">Path Parameters</p>
21607          <table class="params">
21608            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21609            <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>
21610          </table>
21611          <details class="schema"><summary>Response schema</summary>
21612<div class="schema-block">{ "ok": true }</div></details>
21613          <p class="curl-heading">Example</p>
21614          <div class="curl-wrap">
21615            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
21616  -H "Authorization: Bearer $SLOC_API_KEY" \
21617  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
21618            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
21619          </div>
21620        </div>
21621      </div>
21622    </div>
21623
21624    <!-- Scheduled Scans -->
21625    <div class="section">
21626      <h2 class="section-title">Scheduled Scans</h2>
21627
21628      <div class="ep-card">
21629        <div class="ep-header">
21630          <span class="method get">GET</span>
21631          <span class="ep-path">/api/schedules</span>
21632          <span class="auth-badge protected">Protected</span>
21633          <span class="ep-desc">List configured schedules</span>
21634          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21635        </div>
21636        <div class="ep-body">
21637          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
21638          <p class="curl-heading">Example</p>
21639          <div class="curl-wrap">
21640            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21641  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21642            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
21643          </div>
21644        </div>
21645      </div>
21646
21647      <div class="ep-card">
21648        <div class="ep-header">
21649          <span class="method post">POST</span>
21650          <span class="ep-path">/api/schedules</span>
21651          <span class="auth-badge protected">Protected</span>
21652          <span class="ep-desc">Create a schedule</span>
21653          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21654        </div>
21655        <div class="ep-body">
21656          <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>
21657          <p class="curl-heading">Example</p>
21658          <div class="curl-wrap">
21659            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
21660  -H "Authorization: Bearer $SLOC_API_KEY" \
21661  -H "Content-Type: application/json" \
21662  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
21663  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21664            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
21665          </div>
21666        </div>
21667      </div>
21668
21669      <div class="ep-card">
21670        <div class="ep-header">
21671          <span class="method delete">DELETE</span>
21672          <span class="ep-path">/api/schedules</span>
21673          <span class="auth-badge protected">Protected</span>
21674          <span class="ep-desc">Delete a schedule</span>
21675          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21676        </div>
21677        <div class="ep-body">
21678          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
21679          <p class="curl-heading">Example</p>
21680          <div class="curl-wrap">
21681            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
21682  -H "Authorization: Bearer $SLOC_API_KEY" \
21683  -H "Content-Type: application/json" \
21684  -d '{"id":"&lt;schedule_id&gt;"}' \
21685  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
21686            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
21687          </div>
21688        </div>
21689      </div>
21690    </div>
21691
21692    <!-- Git Browser -->
21693    <div class="section">
21694      <h2 class="section-title">Git Browser</h2>
21695
21696      <div class="ep-card">
21697        <div class="ep-header">
21698          <span class="method get">GET</span>
21699          <span class="ep-path">/api/git/refs</span>
21700          <span class="auth-badge protected">Protected</span>
21701          <span class="ep-desc">List git refs for a repository</span>
21702          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21703        </div>
21704        <div class="ep-body">
21705          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
21706          <p class="params-heading">Query Parameters</p>
21707          <table class="params">
21708            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21709            <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>
21710          </table>
21711          <p class="curl-heading">Example</p>
21712          <div class="curl-wrap">
21713            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21714  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
21715            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
21716          </div>
21717        </div>
21718      </div>
21719
21720      <div class="ep-card">
21721        <div class="ep-header">
21722          <span class="method get">GET</span>
21723          <span class="ep-path">/api/git/scan-ref</span>
21724          <span class="auth-badge protected">Protected</span>
21725          <span class="ep-desc">SLOC-scan a specific git ref</span>
21726          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21727        </div>
21728        <div class="ep-body">
21729          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
21730          <p class="params-heading">Query Parameters</p>
21731          <table class="params">
21732            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21733            <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>
21734            <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>
21735          </table>
21736          <p class="curl-heading">Example</p>
21737          <div class="curl-wrap">
21738            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21739  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
21740            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
21741          </div>
21742        </div>
21743      </div>
21744
21745      <div class="ep-card">
21746        <div class="ep-header">
21747          <span class="method get">GET</span>
21748          <span class="ep-path">/api/git/compare-refs</span>
21749          <span class="auth-badge protected">Protected</span>
21750          <span class="ep-desc">Compare SLOC across two git refs</span>
21751          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21752        </div>
21753        <div class="ep-body">
21754          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
21755          <p class="params-heading">Query Parameters</p>
21756          <table class="params">
21757            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21758            <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>
21759            <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>
21760            <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>
21761          </table>
21762          <p class="curl-heading">Example</p>
21763          <div class="curl-wrap">
21764            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21765  "<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>
21766            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
21767          </div>
21768        </div>
21769      </div>
21770    </div>
21771
21772    <!-- Webhooks -->
21773    <div class="section">
21774      <h2 class="section-title">Webhooks</h2>
21775      <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>
21776
21777      <div class="ep-card">
21778        <div class="ep-header">
21779          <span class="method post">POST</span>
21780          <span class="ep-path">/webhooks/github</span>
21781          <span class="auth-badge hmac">HMAC</span>
21782          <span class="ep-desc">GitHub push event receiver</span>
21783          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21784        </div>
21785        <div class="ep-body">
21786          <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>
21787          <p class="params-heading">Required Headers</p>
21788          <table class="params">
21789            <tr><th>Header</th><th>Value</th></tr>
21790            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
21791            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
21792            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21793          </table>
21794        </div>
21795      </div>
21796
21797      <div class="ep-card">
21798        <div class="ep-header">
21799          <span class="method post">POST</span>
21800          <span class="ep-path">/webhooks/gitlab</span>
21801          <span class="auth-badge hmac">HMAC</span>
21802          <span class="ep-desc">GitLab push event receiver</span>
21803          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21804        </div>
21805        <div class="ep-body">
21806          <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>
21807          <p class="params-heading">Required Headers</p>
21808          <table class="params">
21809            <tr><th>Header</th><th>Value</th></tr>
21810            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
21811            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
21812            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21813          </table>
21814        </div>
21815      </div>
21816
21817      <div class="ep-card">
21818        <div class="ep-header">
21819          <span class="method post">POST</span>
21820          <span class="ep-path">/webhooks/bitbucket</span>
21821          <span class="auth-badge hmac">HMAC</span>
21822          <span class="ep-desc">Bitbucket push event receiver</span>
21823          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21824        </div>
21825        <div class="ep-body">
21826          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
21827          <p class="params-heading">Required Headers</p>
21828          <table class="params">
21829            <tr><th>Header</th><th>Value</th></tr>
21830            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
21831            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
21832          </table>
21833        </div>
21834      </div>
21835    </div>
21836
21837    <!-- Config -->
21838    <div class="section">
21839      <h2 class="section-title">Config Import / Export</h2>
21840
21841      <div class="ep-card">
21842        <div class="ep-header">
21843          <span class="method get">GET</span>
21844          <span class="ep-path">/export-config</span>
21845          <span class="auth-badge protected">Protected</span>
21846          <span class="ep-desc">Export server configuration as JSON</span>
21847          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21848        </div>
21849        <div class="ep-body">
21850          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
21851          <p class="curl-heading">Example</p>
21852          <div class="curl-wrap">
21853            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21854  -o config.json \
21855  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
21856            <button class="curl-copy-btn" data-target="c-export">Copy</button>
21857          </div>
21858        </div>
21859      </div>
21860
21861      <div class="ep-card">
21862        <div class="ep-header">
21863          <span class="method post">POST</span>
21864          <span class="ep-path">/import-config</span>
21865          <span class="auth-badge protected">Protected</span>
21866          <span class="ep-desc">Import server configuration</span>
21867          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21868        </div>
21869        <div class="ep-body">
21870          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
21871          <p class="curl-heading">Example</p>
21872          <div class="curl-wrap">
21873            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
21874  -H "Authorization: Bearer $SLOC_API_KEY" \
21875  -H "Content-Type: application/json" \
21876  -d @config.json \
21877  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
21878            <button class="curl-copy-btn" data-target="c-import">Copy</button>
21879          </div>
21880        </div>
21881      </div>
21882    </div>
21883
21884    <!-- CI Ingest -->
21885    <div class="section">
21886      <h2 class="section-title">CI Ingest</h2>
21887
21888      <div class="ep-card">
21889        <div class="ep-header">
21890          <span class="method post">POST</span>
21891          <span class="ep-path">/api/ingest</span>
21892          <span class="auth-badge protected">Protected</span>
21893          <span class="ep-desc">Push a pre-computed scan result from CI</span>
21894          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21895        </div>
21896        <div class="ep-body">
21897          <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>
21898          <p class="params-heading">Query Parameters</p>
21899          <table class="params">
21900            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21901            <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>
21902          </table>
21903          <p class="params-heading">Request Body (application/json)</p>
21904          <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>
21905          <details class="schema"><summary>Response schema</summary>
21906<div class="schema-block">// 201 Created
21907{
21908  "run_id":   string,  // UUID of the ingested run
21909  "view_url": string   // relative URL to the report page
21910}</div></details>
21911          <p class="curl-heading">Example</p>
21912          <div class="curl-wrap">
21913            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
21914  -H "Authorization: Bearer $SLOC_API_KEY" \
21915  -H "Content-Type: application/json" \
21916  -d @result.json \
21917  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
21918            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
21919          </div>
21920        </div>
21921      </div>
21922    </div>
21923
21924    <!-- Artifact Download -->
21925    <div class="section">
21926      <h2 class="section-title">Artifact Download</h2>
21927
21928      <div class="ep-card">
21929        <div class="ep-header">
21930          <span class="method get">GET</span>
21931          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
21932          <span class="auth-badge protected">Protected</span>
21933          <span class="ep-desc">Download or view a scan artifact</span>
21934          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21935        </div>
21936        <div class="ep-body">
21937          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
21938          <p class="params-heading">Path Parameters</p>
21939          <table class="params">
21940            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21941            <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>
21942            <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>
21943          </table>
21944          <p class="params-heading">Query Parameters</p>
21945          <table class="params">
21946            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21947            <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>
21948          </table>
21949          <p class="curl-heading">Example — download JSON result</p>
21950          <div class="curl-wrap">
21951            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
21952  -o result.json \
21953  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
21954            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
21955          </div>
21956        </div>
21957      </div>
21958    </div>
21959
21960    <!-- Embed Widget -->
21961    <div class="section">
21962      <h2 class="section-title">Embed Widget</h2>
21963
21964      <div class="ep-card">
21965        <div class="ep-header">
21966          <span class="method get">GET</span>
21967          <span class="ep-path">/embed/summary</span>
21968          <span class="auth-badge protected">Protected</span>
21969          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
21970          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
21971        </div>
21972        <div class="ep-body">
21973          <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>
21974          <p class="params-heading">Query Parameters</p>
21975          <table class="params">
21976            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
21977            <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>
21978            <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>
21979          </table>
21980          <p class="curl-heading">Example</p>
21981          <div class="curl-wrap">
21982            <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"
21983        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
21984            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
21985          </div>
21986        </div>
21987      </div>
21988    </div>
21989
21990    <!-- Confluence Integration -->
21991    <div class="section">
21992      <h2 class="section-title">Confluence Integration</h2>
21993
21994      <div class="ep-card">
21995        <div class="ep-header">
21996          <span class="method get">GET</span>
21997          <span class="ep-path">/api/confluence/config</span>
21998          <span class="auth-badge protected">Protected</span>
21999          <span class="ep-desc">Get current Confluence configuration</span>
22000          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22001        </div>
22002        <div class="ep-body">
22003          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
22004          <details class="schema"><summary>Response schema</summary>
22005<div class="schema-block">{
22006  "configured":     boolean,
22007  "tier":           "cloud" | "server",
22008  "base_url":       string,
22009  "username":       string,
22010  "api_token_set":  boolean,
22011  "space_key":      string,
22012  "parent_page_id": string | null,
22013  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
22014}</div></details>
22015          <p class="curl-heading">Example</p>
22016          <div class="curl-wrap">
22017            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22018  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22019            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
22020          </div>
22021        </div>
22022      </div>
22023
22024      <div class="ep-card">
22025        <div class="ep-header">
22026          <span class="method post">POST</span>
22027          <span class="ep-path">/api/confluence/config</span>
22028          <span class="auth-badge protected">Protected</span>
22029          <span class="ep-desc">Save Confluence configuration</span>
22030          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22031        </div>
22032        <div class="ep-body">
22033          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
22034          <p class="params-heading">Request Body (application/json)</p>
22035          <table class="params">
22036            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22037            <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>
22038            <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>
22039            <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>
22040            <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>
22041            <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>
22042            <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>
22043            <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>
22044          </table>
22045          <details class="schema"><summary>Response schema</summary>
22046<div class="schema-block">{ "ok": true }</div></details>
22047          <p class="curl-heading">Example</p>
22048          <div class="curl-wrap">
22049            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
22050  -H "Authorization: Bearer $SLOC_API_KEY" \
22051  -H "Content-Type: application/json" \
22052  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
22053  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
22054            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
22055          </div>
22056        </div>
22057      </div>
22058
22059      <div class="ep-card">
22060        <div class="ep-header">
22061          <span class="method post">POST</span>
22062          <span class="ep-path">/api/confluence/test</span>
22063          <span class="auth-badge protected">Protected</span>
22064          <span class="ep-desc">Test Confluence connection</span>
22065          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22066        </div>
22067        <div class="ep-body">
22068          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
22069          <details class="schema"><summary>Response schema</summary>
22070<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
22071          <p class="curl-heading">Example</p>
22072          <div class="curl-wrap">
22073            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
22074  -H "Authorization: Bearer $SLOC_API_KEY" \
22075  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
22076            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
22077          </div>
22078        </div>
22079      </div>
22080
22081      <div class="ep-card">
22082        <div class="ep-header">
22083          <span class="method post">POST</span>
22084          <span class="ep-path">/api/confluence/post</span>
22085          <span class="auth-badge protected">Protected</span>
22086          <span class="ep-desc">Publish a scan report to Confluence</span>
22087          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22088        </div>
22089        <div class="ep-body">
22090          <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>
22091          <p class="params-heading">Request Body (application/json)</p>
22092          <table class="params">
22093            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22094            <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>
22095            <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>
22096            <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>
22097          </table>
22098          <details class="schema"><summary>Response schema</summary>
22099<div class="schema-block">// 200 OK
22100{ "ok": true, "page_id": string }
22101
22102// 400 / 502 on error
22103{ "ok": false, "error": string }</div></details>
22104          <p class="curl-heading">Example</p>
22105          <div class="curl-wrap">
22106            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
22107  -H "Authorization: Bearer $SLOC_API_KEY" \
22108  -H "Content-Type: application/json" \
22109  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
22110  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
22111            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
22112          </div>
22113        </div>
22114      </div>
22115
22116      <div class="ep-card">
22117        <div class="ep-header">
22118          <span class="method get">GET</span>
22119          <span class="ep-path">/api/confluence/wiki-markup</span>
22120          <span class="auth-badge protected">Protected</span>
22121          <span class="ep-desc">Get Confluence wiki markup for a run</span>
22122          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22123        </div>
22124        <div class="ep-body">
22125          <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>
22126          <p class="params-heading">Query Parameters</p>
22127          <table class="params">
22128            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22129            <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>
22130          </table>
22131          <p class="curl-heading">Example</p>
22132          <div class="curl-wrap">
22133            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22134  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
22135            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
22136          </div>
22137        </div>
22138      </div>
22139    </div>
22140
22141    <!-- Authentication -->
22142    <div class="section">
22143      <h2 class="section-title">Authentication</h2>
22144      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
22145
22146      <div class="ep-card">
22147        <div class="ep-header">
22148          <span class="method get">GET</span>
22149          <span class="ep-path">/auth/login</span>
22150          <span class="auth-badge public">Public</span>
22151          <span class="ep-desc">Login page</span>
22152          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22153        </div>
22154        <div class="ep-body">
22155          <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>
22156          <p class="params-heading">Query Parameters</p>
22157          <table class="params">
22158            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22159            <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>
22160            <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>
22161          </table>
22162        </div>
22163      </div>
22164
22165      <div class="ep-card">
22166        <div class="ep-header">
22167          <span class="method post">POST</span>
22168          <span class="ep-path">/auth/login</span>
22169          <span class="auth-badge public">Public</span>
22170          <span class="ep-desc">Submit credentials and get a session cookie</span>
22171          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22172        </div>
22173        <div class="ep-body">
22174          <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>
22175          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
22176          <table class="params">
22177            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
22178            <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>
22179            <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>
22180          </table>
22181          <p class="curl-heading">Example</p>
22182          <div class="curl-wrap">
22183            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
22184  -d "key=$SLOC_API_KEY&amp;next=/" \
22185  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
22186            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
22187          </div>
22188        </div>
22189      </div>
22190    </div>
22191
22192    <!-- Coverage Suggestion -->
22193    <div class="section">
22194      <h2 class="section-title">Coverage Suggestion</h2>
22195
22196      <div class="ep-card">
22197        <div class="ep-header">
22198          <span class="method get">GET</span>
22199          <span class="ep-path">/api/suggest-coverage</span>
22200          <span class="auth-badge protected">Protected</span>
22201          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
22202          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
22203        </div>
22204        <div class="ep-body">
22205          <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>
22206          <p class="params-heading">Query Parameters</p>
22207          <table class="params">
22208            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
22209            <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>
22210          </table>
22211          <details class="schema"><summary>Response schema</summary>
22212<div class="schema-block">{
22213  "found": string | null,  // absolute path to the coverage file, if detected
22214  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
22215  "hint":  string | null   // shell command to generate coverage if not found
22216}</div></details>
22217          <p class="curl-heading">Example</p>
22218          <div class="curl-wrap">
22219            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
22220  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
22221            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
22222          </div>
22223        </div>
22224      </div>
22225    </div>
22226
22227  </div>
22228
22229  <footer class="site-footer">
22230    local code analysis - metrics, history and reports
22231    &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>
22232    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22233    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22234    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22235    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22236  </footer>
22237
22238  <script nonce="{{ csp_nonce }}">
22239    (function () {
22240      var base = window.location.origin;
22241      document.getElementById('base-url').textContent = base;
22242      document.querySelectorAll('.base-url-slot').forEach(function (el) {
22243        el.textContent = base;
22244      });
22245
22246      document.querySelectorAll('.ep-header').forEach(function (hdr) {
22247        hdr.addEventListener('click', function () {
22248          hdr.closest('.ep-card').classList.toggle('open');
22249        });
22250      });
22251
22252      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
22253        btn.addEventListener('click', function () {
22254          var targetId = btn.dataset.target;
22255          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
22256          if (!pre) return;
22257          navigator.clipboard.writeText(pre.textContent).then(function () {
22258            btn.textContent = 'Copied!';
22259            btn.classList.add('copied');
22260            setTimeout(function () {
22261              btn.textContent = 'Copy';
22262              btn.classList.remove('copied');
22263            }, 2000);
22264          });
22265        });
22266      });
22267
22268      var storageKey = 'oxide-sloc-theme';
22269      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
22270      var themeBtn = document.getElementById('theme-toggle');
22271      if (themeBtn) {
22272        themeBtn.addEventListener('click', function () {
22273          var dark = document.body.classList.toggle('dark-theme');
22274          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
22275        });
22276      }
22277      (function() {
22278        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'}];
22279        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);});}
22280        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22281        var btn=document.getElementById('settings-btn');if(!btn)return;
22282        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22283        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>';
22284        document.body.appendChild(m);
22285        var g=document.getElementById('scheme-grid');
22286        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);});
22287        var cl=document.getElementById('settings-close');
22288        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);
22289        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');});
22290        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22291        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22292      })();
22293      (function randomizeWatermarks() {
22294        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22295        if (!wms.length) return;
22296        var placed = [];
22297        function tooClose(top, left) {
22298          for (var i = 0; i < placed.length; i++) {
22299            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22300            if (dt < 16 && dl < 12) return true;
22301          }
22302          return false;
22303        }
22304        function pick(leftBand) {
22305          for (var attempt = 0; attempt < 50; attempt++) {
22306            var top = Math.random() * 88 + 2;
22307            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22308            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22309          }
22310          var top = Math.random() * 88 + 2;
22311          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22312          placed.push([top, left]); return [top, left];
22313        }
22314        var half = Math.floor(wms.length / 2);
22315        wms.forEach(function (img, i) {
22316          var pos = pick(i < half);
22317          var size = Math.floor(Math.random() * 100 + 120);
22318          var rot = (Math.random() * 360).toFixed(1);
22319          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22320          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;
22321        });
22322      })();
22323      (function spawnCodeParticles() {
22324        var container = document.getElementById('code-particles');
22325        if (!container) return;
22326        var snippets = [
22327          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22328          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22329          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22330          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22331          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22332        ];
22333        var count = 38;
22334        for (var i = 0; i < count; i++) {
22335          (function(idx) {
22336            var el = document.createElement('span');
22337            el.className = 'code-particle';
22338            el.textContent = snippets[idx % snippets.length];
22339            var left = Math.random() * 94 + 2;
22340            var top = Math.random() * 88 + 6;
22341            var dur = (Math.random() * 10 + 9).toFixed(1);
22342            var delay = (Math.random() * 18).toFixed(1);
22343            var rot = (Math.random() * 26 - 13).toFixed(1);
22344            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22345            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
22346            container.appendChild(el);
22347          })(i);
22348        }
22349      })();
22350    }());
22351  </script>
22352</body>
22353</html>
22354"##,
22355    ext = "html"
22356)]
22357struct ApiDocsTemplate {
22358    has_api_key: bool,
22359    csp_nonce: String,
22360    version: &'static str,
22361}