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#![allow(clippy::multiple_crate_versions)]
4
5static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
6static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
7static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
8static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
9static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
10static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
11static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
12static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
13static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
14static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
15static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
16static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
17static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
18static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
19static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
20static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
21static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
22static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
23static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
24static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
25
26pub(crate) mod git_browser;
27pub(crate) mod git_webhook;
28
29use std::{
30    collections::{HashMap, VecDeque},
31    fmt::Write,
32    fs,
33    net::{IpAddr, SocketAddr},
34    path::{Path, PathBuf},
35    process::Stdio,
36    sync::Arc,
37    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
38};
39
40use anyhow::{Context, Result};
41use askama::Template;
42use axum::{
43    body::Body,
44    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
45    http::{header, HeaderValue, Request, StatusCode},
46    middleware::{self, Next},
47    response::{Html, IntoResponse, Response},
48    routing::{get, post},
49    Json, Router,
50};
51use serde::{Deserialize, Serialize};
52use tokio::sync::Mutex;
53use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
54
55use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
56use sloc_git::ScheduleStore;
57
58#[derive(Clone)]
59struct CspNonce(String);
60
61static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
62
63use sloc_core::{
64    analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
65    ScanSummarySnapshot, SummaryTotals,
66};
67use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
68const MAX_CONCURRENT_ANALYSES: usize = 4;
69
70/// Windows-only helpers that force the native file-picker dialog into the
71/// foreground instead of appearing minimised behind other windows.
72///
73/// Strategy: (a) attach the spawn_blocking thread's input queue to the current
74/// foreground thread so that windows created on our thread inherit focus; and
75/// (b) spin a polling watcher that finds the dialog by title and calls
76/// SetForegroundWindow + FlashWindowEx once it appears.
77#[cfg(all(target_os = "windows", feature = "native-dialog"))]
78#[allow(clippy::upper_case_acronyms)]
79mod win_dialog_focus {
80    use std::mem::size_of;
81
82    type HWND = *mut core::ffi::c_void;
83    type DWORD = u32;
84    type UINT = u32;
85    type BOOL = i32;
86
87    // Mirror of FLASHWINFO from winuser.h — field names kept in PascalCase to
88    // match the Win32 ABI layout exactly; the #[allow] suppresses the Rust
89    // naming lint for this one struct.
90    #[repr(C)]
91    #[allow(non_snake_case)]
92    struct FLASHWINFO {
93        cbSize: UINT,
94        hwnd: HWND,
95        dwFlags: DWORD,
96        uCount: UINT,
97        dwTimeout: DWORD,
98    }
99
100    const FLASHW_ALL: DWORD = 0x3;
101    const FLASHW_TIMERNOFG: DWORD = 0xC;
102
103    #[link(name = "user32")]
104    extern "system" {
105        fn GetForegroundWindow() -> HWND;
106        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
107        fn BringWindowToTop(hWnd: HWND) -> BOOL;
108        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
109        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
110        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
111        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
112    }
113
114    #[link(name = "kernel32")]
115    extern "system" {
116        fn GetCurrentThreadId() -> DWORD;
117    }
118
119    /// Attaches our thread's input to the foreground window's thread so that
120    /// windows created on our thread inherit foreground focus.  Returns the
121    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
122    /// the thread was already the foreground thread.
123    pub fn attach_to_foreground() -> DWORD {
124        unsafe {
125            let fg_hwnd = GetForegroundWindow();
126            if fg_hwnd.is_null() {
127                return 0;
128            }
129            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
130            let my_tid = GetCurrentThreadId();
131            if fg_tid == my_tid {
132                return 0;
133            }
134            AttachThreadInput(my_tid, fg_tid, 1);
135            fg_tid
136        }
137    }
138
139    /// Undoes `attach_to_foreground`.
140    pub fn detach_from_foreground(fg_tid: DWORD) {
141        if fg_tid == 0 {
142            return;
143        }
144        unsafe {
145            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
146        }
147    }
148
149    /// Spawns a short-lived watcher thread that polls for a dialog window
150    /// matching `title` and, once found, forces it to the foreground and
151    /// flashes its taskbar button until the user interacts with it.
152    pub fn flash_dialog_when_ready(title: String) {
153        std::thread::spawn(move || {
154            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
155            for _ in 0..40 {
156                std::thread::sleep(std::time::Duration::from_millis(80));
157                unsafe {
158                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
159                    if !hwnd.is_null() {
160                        SetForegroundWindow(hwnd);
161                        BringWindowToTop(hwnd);
162                        #[allow(non_snake_case)]
163                        FlashWindowEx(&FLASHWINFO {
164                            cbSize: size_of::<FLASHWINFO>() as UINT,
165                            hwnd,
166                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
167                            uCount: 3,
168                            dwTimeout: 0,
169                        });
170                        break;
171                    }
172                }
173            }
174        });
175    }
176}
177
178/// Sliding-window rate limiter keyed by client IP.
179/// Uses only std primitives — no external crate required.
180struct IpRateLimiter {
181    window: Duration,
182    max_requests: usize,
183    auth_lockout_threshold: u32,
184    auth_lockout_window: Duration,
185    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
186    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
187}
188
189impl IpRateLimiter {
190    fn new(
191        window: Duration,
192        max_requests: usize,
193        auth_lockout_threshold: u32,
194        auth_lockout_window: Duration,
195    ) -> Self {
196        Self {
197            window,
198            max_requests,
199            auth_lockout_threshold,
200            auth_lockout_window,
201            state: std::sync::Mutex::new(HashMap::new()),
202            auth_failures: std::sync::Mutex::new(HashMap::new()),
203        }
204    }
205
206    // The MutexGuard `state` must live as long as `bucket` borrows from it,
207    // so it cannot be dropped any earlier than the end of the inner block.
208    #[allow(clippy::significant_drop_tightening)]
209    fn is_allowed(&self, ip: IpAddr) -> bool {
210        let now = Instant::now();
211        let cutoff = now.checked_sub(self.window).unwrap_or(now);
212        let mut state = self
213            .state
214            .lock()
215            .unwrap_or_else(std::sync::PoisonError::into_inner);
216        if state.len() > 10_000 {
217            state.retain(|_, bucket| {
218                while bucket.front().is_some_and(|t| *t <= cutoff) {
219                    bucket.pop_front();
220                }
221                !bucket.is_empty()
222            });
223        }
224        let bucket = state.entry(ip).or_default();
225        while bucket.front().is_some_and(|t| *t <= cutoff) {
226            bucket.pop_front();
227        }
228        if bucket.len() >= self.max_requests {
229            false
230        } else {
231            bucket.push_back(now);
232            true
233        }
234    }
235
236    fn record_auth_failure(&self, ip: IpAddr) {
237        let now = Instant::now();
238        let mut map = self
239            .auth_failures
240            .lock()
241            .unwrap_or_else(std::sync::PoisonError::into_inner);
242        map.entry(ip)
243            .and_modify(|e| {
244                e.0 += 1;
245                e.1 = now;
246            })
247            .or_insert_with(|| (1, now));
248    }
249
250    fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
251        let mut map = self
252            .auth_failures
253            .lock()
254            .unwrap_or_else(std::sync::PoisonError::into_inner);
255        let expired = map
256            .get(&ip)
257            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
258        if expired {
259            map.remove(&ip);
260            return false;
261        }
262        map.get(&ip)
263            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
264    }
265
266    fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
267        let map = self
268            .auth_failures
269            .lock()
270            .unwrap_or_else(std::sync::PoisonError::into_inner);
271        map.get(&ip).map_or(0, |e| {
272            self.auth_lockout_window
273                .checked_sub(e.1.elapsed())
274                .map_or(0, |r| r.as_secs())
275        })
276    }
277}
278
279/// Carries context from scan time to result render time (stored inside RunArtifacts).
280#[derive(Clone, Debug, Default)]
281struct RunResultContext {
282    prev_entry: Option<RegistryEntry>,
283    prev_scan_count: usize,
284    project_path: String,
285}
286
287/// State of a background async scan, keyed by wait_id in AppState::async_runs.
288#[derive(Clone)]
289enum AsyncRunState {
290    Running {
291        started_at: std::time::Instant,
292    },
293    /// run_id so the status endpoint can redirect to /runs/{run_id}/result.
294    Complete {
295        run_id: String,
296    },
297    Failed {
298        message: String,
299    },
300}
301
302#[derive(Clone)]
303struct AppState {
304    base_config: AppConfig,
305    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
306    async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
307    registry: Arc<Mutex<ScanRegistry>>,
308    registry_path: PathBuf,
309    analyze_semaphore: Arc<tokio::sync::Semaphore>,
310    server_mode: bool,
311    tls_enabled: bool,
312    api_keys: Vec<secrecy::Secret<String>>,
313    rate_limiter: Arc<IpRateLimiter>,
314    trust_proxy: bool,
315    /// Directory where remote repositories are cloned for git-browser scans.
316    git_clones_dir: PathBuf,
317    /// Persisted list of webhook / poll schedules.
318    schedules: Arc<Mutex<ScheduleStore>>,
319    schedules_path: PathBuf,
320}
321
322type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
323
324/// Parameters for the fire-and-forget HTML + PDF background task.
325
326#[derive(Clone, Debug)]
327pub(crate) struct RunArtifacts {
328    output_dir: PathBuf,
329    html_path: Option<PathBuf>,
330    pdf_path: Option<PathBuf>,
331    json_path: Option<PathBuf>,
332    scan_config_path: Option<PathBuf>,
333    report_title: String,
334    result_context: RunResultContext,
335}
336
337fn build_router(state: AppState) -> Router {
338    let protected = Router::new()
339        .route("/", get(splash))
340        .route("/scan-setup", get(scan_setup_handler))
341        .route("/scan", get(index))
342        .route("/analyze", post(analyze_handler))
343        .route("/preview", get(preview_handler))
344        .route("/pick-directory", get(pick_directory_handler))
345        .route("/open-path", get(open_path_handler))
346        .route("/pick-file", get(pick_file_handler))
347        .route("/locate-report", post(locate_report_handler))
348        .route("/locate-reports-dir", post(locate_reports_dir_handler))
349        .route("/view-reports", get(history_handler))
350        .route("/compare-scans", get(compare_select_handler))
351        .route("/compare", get(compare_handler))
352        .route("/images/{folder}/{file}", get(image_handler))
353        .route("/runs/{run_id}/{artifact}", get(artifact_handler))
354        .route("/api/metrics/latest", get(api_metrics_latest_handler))
355        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
356        .route("/api/project-history", get(project_history_handler))
357        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
358        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
359        .route("/runs/{run_id}/result", get(async_run_result_handler))
360        .route("/embed/summary", get(embed_handler))
361        // ── Git browser ────────────────────────────────────────────────────────
362        .route("/git-browser", get(git_browser::git_browser_handler))
363        .route("/api/git/refs", get(git_browser::api_list_refs))
364        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
365        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
366        // ── Webhook + schedule management ──────────────────────────────────────
367        .route("/webhook-setup", get(git_webhook::webhook_setup_handler))
368        .route("/api/schedules", get(git_webhook::api_list_schedules))
369        .route("/api/schedules", post(git_webhook::api_create_schedule))
370        .route(
371            "/api/schedules",
372            axum::routing::delete(git_webhook::api_delete_schedule),
373        )
374        .route_layer(middleware::from_fn_with_state(
375            state.clone(),
376            require_api_key,
377        ));
378
379    protected
380        .route("/healthz", get(healthz))
381        .route("/badge/{metric}", get(badge_handler))
382        .route("/static/chart.js", get(chart_js_handler))
383        .route("/auth/login", get(auth_login_get))
384        .route("/auth/login", post(auth_login_post))
385        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
386        .route("/webhooks/github", post(git_webhook::handle_github_webhook))
387        .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
388        .route(
389            "/webhooks/bitbucket",
390            post(git_webhook::handle_bitbucket_webhook),
391        )
392        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
393        .layer(middleware::from_fn_with_state(
394            state.clone(),
395            add_security_headers,
396        ))
397        .layer(build_cors_layer(state.server_mode))
398        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
399        .with_state(state)
400}
401
402/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
403pub fn make_test_router() -> Router {
404    let tmp = std::env::temp_dir().join("sloc_test");
405    let state = AppState {
406        base_config: AppConfig::default(),
407        artifacts: Arc::new(Mutex::new(HashMap::new())),
408        async_runs: Arc::new(Mutex::new(HashMap::new())),
409        registry: Arc::new(Mutex::new(ScanRegistry::default())),
410        registry_path: tmp.join("registry.json"),
411        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
412        server_mode: false,
413        tls_enabled: false,
414        api_keys: vec![],
415        rate_limiter: Arc::new(IpRateLimiter::new(
416            Duration::from_secs(60),
417            600,
418            10,
419            Duration::from_secs(3600),
420        )),
421        trust_proxy: false,
422        git_clones_dir: tmp.join("git-clones"),
423        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
424        schedules_path: tmp.join("schedules.json"),
425    };
426    build_router(state)
427}
428
429/// # Errors
430///
431/// Returns an error if the server fails to bind to the configured address or
432/// if the TLS configuration cannot be loaded.
433///
434/// # Panics
435///
436/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
437// The function coordinates TLS setup, router construction, and async listener setup in one
438// place; splitting it further would require passing many state values across function boundaries.
439#[allow(clippy::too_many_lines)]
440pub async fn serve(config: AppConfig) -> Result<()> {
441    let bind_address = config.web.bind_address.clone();
442    let server_mode = config.web.server_mode;
443    let output_root = resolve_output_root(None);
444    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
445    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
446        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
447    let mut registry = ScanRegistry::load(&registry_path);
448    registry.prune_stale();
449    let _ = registry.save(&registry_path);
450
451    let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
452        .or_else(|_| std::env::var("SLOC_API_KEY"))
453        .unwrap_or_default()
454        .split(',')
455        .map(str::trim)
456        .filter(|s| !s.is_empty())
457        .map(|s| secrecy::Secret::new(s.to_owned()))
458        .collect();
459    if server_mode && api_keys.is_empty() {
460        println!(
461            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
462             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
463        );
464    }
465
466    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
467    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
468    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
469    if server_mode && !tls_enabled {
470        println!(
471            "WARNING: TLS is not configured. Traffic is cleartext. \
472             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
473             or terminate TLS at a reverse proxy (nginx, caddy)."
474        );
475    }
476    if server_mode {
477        println!(
478            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
479             to restrict cross-origin access (comma-separated)."
480        );
481    }
482    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
483    if trust_proxy {
484        println!(
485            "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
486             Only set this when oxide-sloc is behind a trusted reverse proxy."
487        );
488    }
489
490    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
491        .ok()
492        .and_then(|v| v.parse::<u32>().ok())
493        .unwrap_or(10);
494    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
495        .ok()
496        .and_then(|v| v.parse::<u64>().ok())
497        .unwrap_or(3600);
498    // 600 req/min per IP across all routes (10/sec — suits local/air-gapped use).
499    let rate_limiter = Arc::new(IpRateLimiter::new(
500        Duration::from_mins(1),
501        600,
502        auth_lockout_threshold,
503        Duration::from_secs(auth_lockout_secs),
504    ));
505
506    let git_clones_dir = resolve_git_clones_dir(&output_root);
507    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
508        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
509    let schedules = ScheduleStore::load(&schedules_path);
510
511    let state = AppState {
512        base_config: config,
513        artifacts: Arc::new(Mutex::new(HashMap::new())),
514        async_runs: Arc::new(Mutex::new(HashMap::new())),
515        registry: Arc::new(Mutex::new(registry)),
516        registry_path,
517        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
518        server_mode,
519        tls_enabled,
520        api_keys,
521        rate_limiter,
522        trust_proxy,
523        git_clones_dir,
524        schedules: Arc::new(Mutex::new(schedules)),
525        schedules_path,
526    };
527
528    restart_poll_schedules(&state).await;
529
530    let app = build_router(state.clone());
531
532    // Try the configured port first, then step up through a few alternatives.
533    // On Windows, a killed process can leave its LISTEN socket as an unkillable
534    // kernel zombie (visible in netstat but owned by no living process).  Rather
535    // than failing, we auto-select the next free port and tell the user.
536    let preferred: SocketAddr = bind_address
537        .parse()
538        .with_context(|| format!("invalid bind address: {bind_address}"))?;
539    let (listener, addr) = {
540        let candidates = (0u16..=9).map(|offset| {
541            let mut a = preferred;
542            a.set_port(preferred.port().saturating_add(offset));
543            a
544        });
545        let mut found = None;
546        for candidate in candidates {
547            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
548                found = Some((l, candidate));
549                break;
550            }
551        }
552        found.ok_or_else(|| {
553            anyhow::anyhow!(
554                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
555                bind_address,
556                preferred.port(),
557                preferred.port().saturating_add(9)
558            )
559        })?
560    };
561    if addr != preferred {
562        eprintln!(
563            "NOTE: port {} is blocked by a system socket (Windows zombie); \
564             using {} instead.",
565            preferred.port(),
566            addr.port()
567        );
568    }
569
570    if tls_enabled {
571        let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
572        let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
573        let tls_config = build_tls_config(&cert_path, &key_path)
574            .context("failed to load TLS certificate/key")?;
575        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
576
577        let url = format!("https://{addr}/");
578        println!("OxideSLOC server running at {url} (TLS)");
579        println!("Use Ctrl+C to stop.");
580
581        return serve_tls(listener, app, acceptor, server_mode).await;
582    }
583
584    let url = format!("http://{addr}/");
585    log_startup_url(&url, server_mode);
586
587    axum::serve(
588        listener,
589        app.into_make_service_with_connect_info::<SocketAddr>(),
590    )
591    .with_graceful_shutdown(shutdown_signal(server_mode))
592    .await
593    .context("web server terminated unexpectedly")
594}
595
596/// Discover the primary non-loopback IPv4 address by asking the OS which
597/// outbound interface it would use to reach a public address.  No packets are
598/// sent — the UDP socket is only used to query the routing table.
599fn primary_lan_ip() -> Option<String> {
600    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
601    socket.connect("8.8.8.8:80").ok()?;
602    let addr = socket.local_addr().ok()?;
603    let ip = addr.ip();
604    if ip.is_loopback() {
605        return None;
606    }
607    Some(ip.to_string())
608}
609
610/// Print the startup URL and, in local mode, open the browser and schedule it.
611fn log_startup_url(url: &str, server_mode: bool) {
612    if server_mode {
613        println!("OxideSLOC server running at {url}");
614        println!("Use Ctrl+C to stop.");
615    } else {
616        println!("OxideSLOC local web UI running at {url}");
617        println!("Press Ctrl+C to stop the server.");
618        let open_url = url.to_owned();
619        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
620    }
621}
622
623/// Open the given URL in the default system browser.
624fn open_browser_tab(url: &str) {
625    #[cfg(target_os = "windows")]
626    let _ = std::process::Command::new("cmd")
627        .args(["/c", "start", "", url])
628        .stdout(Stdio::null())
629        .stderr(Stdio::null())
630        .spawn();
631    #[cfg(target_os = "macos")]
632    let _ = std::process::Command::new("open")
633        .arg(url)
634        .stdout(Stdio::null())
635        .stderr(Stdio::null())
636        .spawn();
637    #[cfg(target_os = "linux")]
638    let _ = std::process::Command::new("xdg-open")
639        .arg(url)
640        .stdout(Stdio::null())
641        .stderr(Stdio::null())
642        .spawn();
643}
644
645/// Graceful-shutdown future: resolves on Ctrl-C.
646async fn shutdown_signal(server_mode: bool) {
647    if tokio::signal::ctrl_c().await.is_ok() {
648        println!();
649        if server_mode {
650            println!("Shutting down OxideSLOC server...");
651        } else {
652            println!("Shutting down OxideSLOC local web UI...");
653        }
654        println!("Server stopped cleanly.");
655    }
656}
657
658/// Load a rustls `ServerConfig` from PEM certificate and key files.
659fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
660    use rustls_pemfile::{certs, private_key};
661    use std::io::BufReader;
662
663    let cert_bytes =
664        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
665    let key_bytes =
666        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
667
668    let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
669        .collect::<std::result::Result<_, _>>()
670        .context("failed to parse TLS certificates")?;
671
672    let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
673        .context("failed to parse TLS private key")?
674        .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
675
676    rustls::ServerConfig::builder()
677        .with_no_client_auth()
678        .with_single_cert(cert_chain, key)
679        .context("failed to build TLS server config")
680}
681
682/// Accept loop with TLS termination using tokio-rustls + hyper-util.
683async fn serve_tls(
684    listener: tokio::net::TcpListener,
685    app: Router,
686    acceptor: tokio_rustls::TlsAcceptor,
687    server_mode: bool,
688) -> Result<()> {
689    use hyper_util::rt::{TokioExecutor, TokioIo};
690    use hyper_util::server::conn::auto::Builder as ConnBuilder;
691    use hyper_util::service::TowerToHyperService;
692    use tower::{Service, ServiceExt};
693
694    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
695
696    loop {
697        tokio::select! {
698            biased;
699            _ = tokio::signal::ctrl_c() => {
700                println!();
701                if server_mode {
702                    println!("Shutting down OxideSLOC server...");
703                } else {
704                    println!("Shutting down OxideSLOC local web UI...");
705                }
706                println!("Server stopped cleanly.");
707                return Ok(());
708            }
709            result = listener.accept() => {
710                let (tcp, peer_addr) = result.context("TLS accept failed")?;
711                let acceptor = acceptor.clone();
712                let mut factory = make_svc.clone();
713
714                tokio::spawn(async move {
715                    let tls = match acceptor.accept(tcp).await {
716                        Ok(s) => s,
717                        Err(e) => {
718                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
719                            return;
720                        }
721                    };
722                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
723                        Ok(f) => match Service::call(f, peer_addr).await {
724                            Ok(s) => s,
725                            Err(_) => return,
726                        },
727                        Err(_) => return,
728                    };
729                    let io = TokioIo::new(tls);
730                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
731                        .serve_connection(io, TowerToHyperService::new(svc))
732                        .await
733                    {
734                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
735                    }
736                });
737            }
738        }
739    }
740}
741
742async fn require_api_key(
743    State(state): State<AppState>,
744    req: Request<Body>,
745    next: Next,
746) -> Response {
747    if state.api_keys.is_empty() {
748        return next.run(req).await;
749    }
750
751    let keys = &state.api_keys;
752    let peer_ip = req
753        .extensions()
754        .get::<axum::extract::ConnectInfo<SocketAddr>>()
755        .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
756
757    // Collect credentials from all three sources: Bearer header, X-API-Key, session cookie.
758    let auth_header = req
759        .headers()
760        .get(header::AUTHORIZATION)
761        .and_then(|v| v.to_str().ok())
762        .and_then(|v| v.strip_prefix("Bearer "))
763        .map(str::to_owned);
764    let x_api_key = req
765        .headers()
766        .get("X-API-Key")
767        .and_then(|v| v.to_str().ok())
768        .map(str::to_owned);
769    let session_cookie = req
770        .headers()
771        .get(header::COOKIE)
772        .and_then(|v| v.to_str().ok())
773        .and_then(extract_session_cookie)
774        .map(str::to_owned);
775
776    let any_credential_provided =
777        auth_header.is_some() || x_api_key.is_some() || session_cookie.is_some();
778
779    let valid = [&auth_header, &x_api_key, &session_cookie]
780        .iter()
781        .filter_map(|o| o.as_deref())
782        .any(|k| {
783            keys.iter().any(|expected| {
784                use secrecy::ExposeSecret;
785                ct_eq(k, expected.expose_secret())
786            })
787        });
788
789    if valid {
790        return next.run(req).await;
791    }
792
793    if state.rate_limiter.is_auth_locked_out(peer_ip) {
794        tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
795            "Authentication locked out after repeated failures");
796        let remaining = state.rate_limiter.auth_lockout_remaining_secs(peer_ip);
797        let retry_after = HeaderValue::from_str(&remaining.to_string())
798            .unwrap_or(HeaderValue::from_static("3600"));
799        if is_browser_request(&req) {
800            let minutes = remaining.div_ceil(60).max(1);
801            let s = if minutes == 1 { "" } else { "s" };
802            let body = format!(
803                r#"<!doctype html><html><head><meta charset="utf-8">
804<title>Locked Out — OxideSLOC</title>
805<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#2f241c}}
806h1{{color:#b85d33}}p{{line-height:1.6}}code{{background:#f3e9e0;padding:2px 6px;border-radius:4px}}</style>
807</head><body>
808<h1>Too many failed sign-in attempts</h1>
809<p>Access from your IP is temporarily locked. Lockout expires in approximately
810<strong>{minutes} minute{s}</strong>.</p>
811<p>To clear immediately, restart the server.</p>
812<p>For trusted LAN testing, leave <code>SLOC_API_KEY</code> unset, or raise the
813threshold via <code>SLOC_AUTH_LOCKOUT_FAILS</code> / <code>SLOC_AUTH_LOCKOUT_SECS</code>.</p>
814</body></html>"#
815            );
816            let mut resp = (StatusCode::TOO_MANY_REQUESTS, Html(body)).into_response();
817            resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
818            return resp;
819        }
820        let mut resp = (
821            StatusCode::TOO_MANY_REQUESTS,
822            format!("429 Too Many Requests — locked out, retry in {remaining}s\n"),
823        )
824            .into_response();
825        resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
826        return resp;
827    }
828
829    if any_credential_provided {
830        // A credential was supplied but didn't match — record the failure.
831        state.rate_limiter.record_auth_failure(peer_ip);
832        let path = req.uri().path().to_owned();
833        tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
834            "API key authentication failed");
835        return (
836            StatusCode::UNAUTHORIZED,
837            [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
838            "401 Unauthorized\n",
839        )
840            .into_response();
841    }
842
843    // No credential supplied at all.  Redirect browsers to the login form; return
844    // a plain 401 for API clients (without recording a failure — unauthenticated
845    // browser page loads should not burn the lockout counter).
846    if is_browser_request(&req) {
847        let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
848        let login_url = format!("/auth/login?next={}", urlencode_path(next_path));
849        let location = HeaderValue::from_str(&login_url)
850            .unwrap_or_else(|_| HeaderValue::from_static("/auth/login"));
851        let mut resp = StatusCode::FOUND.into_response();
852        resp.headers_mut().insert(header::LOCATION, location);
853        return resp;
854    }
855
856    (
857        StatusCode::UNAUTHORIZED,
858        [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
859        "401 Unauthorized\n",
860    )
861        .into_response()
862}
863
864fn ct_eq(a: &str, b: &str) -> bool {
865    use subtle::ConstantTimeEq;
866    a.as_bytes().ct_eq(b.as_bytes()).into()
867}
868
869fn extract_session_cookie(cookie_header: &str) -> Option<&str> {
870    cookie_header.split(';').find_map(|pair| {
871        let pair = pair.trim();
872        let (k, v) = pair.split_once('=')?;
873        if k.trim() == "sloc_session" {
874            Some(v.trim())
875        } else {
876            None
877        }
878    })
879}
880
881fn is_browser_request(req: &Request<Body>) -> bool {
882    req.headers()
883        .get(header::ACCEPT)
884        .and_then(|v| v.to_str().ok())
885        .is_some_and(|a| a.contains("text/html"))
886}
887
888fn urlencode_path(s: &str) -> String {
889    let mut out = String::with_capacity(s.len());
890    for b in s.bytes() {
891        match b {
892            b'A'..=b'Z'
893            | b'a'..=b'z'
894            | b'0'..=b'9'
895            | b'-'
896            | b'_'
897            | b'.'
898            | b'~'
899            | b'/'
900            | b'?'
901            | b'='
902            | b'&'
903            | b'#' => {
904                out.push(b as char);
905            }
906            _ => {
907                use std::fmt::Write as _;
908                write!(&mut out, "%{b:02X}").ok();
909            }
910        }
911    }
912    out
913}
914
915// ── Login form handlers ────────────────────────────────────────────────────────
916
917#[derive(serde::Deserialize)]
918struct LoginQuery {
919    next: Option<String>,
920    error: Option<String>,
921}
922
923#[derive(serde::Deserialize)]
924struct LoginFormData {
925    key: String,
926    next: Option<String>,
927}
928
929async fn auth_login_get(
930    State(state): State<AppState>,
931    Query(query): Query<LoginQuery>,
932    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
933) -> Response {
934    if state.api_keys.is_empty() {
935        let mut resp = StatusCode::FOUND.into_response();
936        resp.headers_mut()
937            .insert(header::LOCATION, HeaderValue::from_static("/"));
938        return resp;
939    }
940    let has_error = query.error.as_deref() == Some("1");
941    let next_url = query.next.unwrap_or_default();
942    let lockout_threshold = state.rate_limiter.auth_lockout_threshold;
943    Html(
944        LoginTemplate {
945            csp_nonce,
946            has_error,
947            next_url,
948            lockout_threshold,
949        }
950        .render()
951        .unwrap_or_else(|e| format!("<pre>Template error: {e}</pre>")),
952    )
953    .into_response()
954}
955
956async fn auth_login_post(
957    State(state): State<AppState>,
958    axum::extract::ConnectInfo(peer_addr): axum::extract::ConnectInfo<SocketAddr>,
959    Form(form): Form<LoginFormData>,
960) -> Response {
961    let peer_ip = peer_addr.ip();
962    let next_url = form
963        .next
964        .as_deref()
965        .filter(|s| !s.is_empty())
966        .unwrap_or("/");
967    let safe_next = if next_url.starts_with('/') {
968        next_url
969    } else {
970        "/"
971    };
972
973    let valid = state.api_keys.iter().any(|expected| {
974        use secrecy::ExposeSecret;
975        ct_eq(&form.key, expected.expose_secret())
976    });
977
978    if valid {
979        let secure_flag = if state.tls_enabled { "; Secure" } else { "" };
980        let cookie_value = format!(
981            "sloc_session={}; Path=/; HttpOnly; SameSite=Strict{}",
982            form.key, secure_flag,
983        );
984        let location =
985            HeaderValue::from_str(safe_next).unwrap_or_else(|_| HeaderValue::from_static("/"));
986        let cookie_hv = HeaderValue::from_str(&cookie_value)
987            .unwrap_or_else(|_| HeaderValue::from_static("sloc_session=; Path=/; HttpOnly"));
988        let mut resp = StatusCode::FOUND.into_response();
989        resp.headers_mut().insert(header::LOCATION, location);
990        resp.headers_mut().insert(header::SET_COOKIE, cookie_hv);
991        resp
992    } else {
993        state.rate_limiter.record_auth_failure(peer_ip);
994        tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = "/auth/login",
995            "Login form authentication failed");
996        let error_url = format!("/auth/login?next={}&error=1", urlencode_path(safe_next));
997        let location = HeaderValue::from_str(&error_url)
998            .unwrap_or_else(|_| HeaderValue::from_static("/auth/login?error=1"));
999        let mut resp = StatusCode::FOUND.into_response();
1000        resp.headers_mut().insert(header::LOCATION, location);
1001        resp
1002    }
1003}
1004
1005fn build_cors_layer(server_mode: bool) -> CorsLayer {
1006    if server_mode {
1007        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1008            .unwrap_or_default()
1009            .split(',')
1010            .filter(|s| !s.is_empty())
1011            .filter_map(|s| s.trim().parse().ok())
1012            .collect();
1013        if allowed.is_empty() {
1014            return CorsLayer::new();
1015        }
1016        CorsLayer::new()
1017            .allow_origin(AllowOrigin::list(allowed))
1018            .allow_methods(AllowMethods::list([
1019                axum::http::Method::GET,
1020                axum::http::Method::POST,
1021            ]))
1022            .allow_headers(AllowHeaders::list([
1023                axum::http::header::AUTHORIZATION,
1024                axum::http::header::CONTENT_TYPE,
1025            ]))
1026    } else {
1027        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1028            let s = origin.to_str().unwrap_or("");
1029            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1030        }))
1031    }
1032}
1033
1034async fn add_security_headers(
1035    State(state): State<AppState>,
1036    mut req: Request<Body>,
1037    next: Next,
1038) -> Response {
1039    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1040    req.extensions_mut().insert(CspNonce(nonce.clone()));
1041    let mut resp = next.run(req).await;
1042    let h = resp.headers_mut();
1043    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1044    h.insert(
1045        "X-Content-Type-Options",
1046        HeaderValue::from_static("nosniff"),
1047    );
1048    h.insert(
1049        "Referrer-Policy",
1050        HeaderValue::from_static("strict-origin-when-cross-origin"),
1051    );
1052    let csp = format!(
1053        "default-src 'self'; \
1054         style-src 'self' 'nonce-{nonce}'; \
1055         img-src 'self' data: blob:; \
1056         script-src 'self' 'nonce-{nonce}'; \
1057         font-src 'self' data:; \
1058         object-src 'none'; \
1059         frame-ancestors 'none'"
1060    );
1061    h.insert(
1062        "Content-Security-Policy",
1063        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1064            HeaderValue::from_static(
1065                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1066            )
1067        }),
1068    );
1069    h.insert(
1070        "X-Permitted-Cross-Domain-Policies",
1071        HeaderValue::from_static("none"),
1072    );
1073    h.insert(
1074        "Permissions-Policy",
1075        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1076    );
1077    h.insert(
1078        "Cross-Origin-Opener-Policy",
1079        HeaderValue::from_static("same-origin"),
1080    );
1081    h.insert(
1082        "Cross-Origin-Resource-Policy",
1083        HeaderValue::from_static("same-origin"),
1084    );
1085    if state.tls_enabled {
1086        h.insert(
1087            "Strict-Transport-Security",
1088            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1089        );
1090    }
1091    resp
1092}
1093
1094async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1095    let ip = req
1096        .extensions()
1097        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1098        .map(|c| c.0.ip())
1099        .or_else(|| {
1100            if state.trust_proxy {
1101                req.headers()
1102                    .get("X-Forwarded-For")
1103                    .and_then(|v| v.to_str().ok())
1104                    .and_then(|s| s.split(',').next())
1105                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1106            } else {
1107                None
1108            }
1109        })
1110        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1111
1112    if !state.rate_limiter.is_allowed(ip) {
1113        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1114            path = %req.uri().path(), "Rate limit exceeded");
1115        return (
1116            StatusCode::TOO_MANY_REQUESTS,
1117            [(header::RETRY_AFTER, "60")],
1118            "429 Too Many Requests\n",
1119        )
1120            .into_response();
1121    }
1122    next.run(req).await
1123}
1124
1125async fn splash(
1126    State(state): State<AppState>,
1127    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1128) -> impl IntoResponse {
1129    let lan_ip = if state.server_mode {
1130        primary_lan_ip()
1131    } else {
1132        None
1133    };
1134    let port = state
1135        .base_config
1136        .web
1137        .bind_address
1138        .rsplit(':')
1139        .next()
1140        .and_then(|p| p.parse::<u16>().ok())
1141        .unwrap_or(4317);
1142    let template = SplashTemplate {
1143        csp_nonce,
1144        server_mode: state.server_mode,
1145        lan_ip,
1146        port,
1147        version: env!("CARGO_PKG_VERSION"),
1148    };
1149    Html(
1150        template
1151            .render()
1152            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1153    )
1154}
1155
1156async fn index(
1157    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1158    Query(query): Query<IndexQuery>,
1159) -> impl IntoResponse {
1160    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1161        let policy = query
1162            .mixed_line_policy
1163            .unwrap_or_else(|| "code_only".to_string());
1164        let behavior = query
1165            .binary_file_behavior
1166            .unwrap_or_else(|| "skip".to_string());
1167        let cfg = ScanConfig {
1168            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1169            path: query.path.unwrap_or_default(),
1170            include_globs: query.include_globs.unwrap_or_default(),
1171            exclude_globs: query.exclude_globs.unwrap_or_default(),
1172            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1173            mixed_line_policy: policy,
1174            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1175                != Some("off"),
1176            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1177            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1178            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1179                != Some("disabled"),
1180            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1181            binary_file_behavior: behavior,
1182            output_dir: query.output_dir.unwrap_or_default(),
1183            report_title: query.report_title.unwrap_or_default(),
1184            generate_html: query.generate_html.as_deref() != Some("off"),
1185            generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1186        };
1187        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1188    } else {
1189        "{}".to_string()
1190    };
1191
1192    let git_repo = query.git_repo.unwrap_or_default();
1193    let git_ref = query.git_ref.unwrap_or_default();
1194
1195    let git_label = make_git_label(&git_repo, &git_ref);
1196    let git_output_dir = if git_label.is_empty() {
1197        String::new()
1198    } else {
1199        desktop_dir().join(&git_label).display().to_string()
1200    };
1201    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1202    let git_output_dir_json =
1203        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1204
1205    let template = IndexTemplate {
1206        version: env!("CARGO_PKG_VERSION"),
1207        prefill_json,
1208        csp_nonce,
1209        git_repo,
1210        git_ref,
1211        git_label_json,
1212        git_output_dir_json,
1213    };
1214
1215    Html(
1216        template
1217            .render()
1218            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1219    )
1220}
1221
1222async fn scan_setup_handler(
1223    State(state): State<AppState>,
1224    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1225) -> impl IntoResponse {
1226    let recent_scans_json = {
1227        let arr: Vec<serde_json::Value> = {
1228            let reg = state.registry.lock().await;
1229            reg.entries
1230                .iter()
1231                .rev()
1232                .take(6)
1233                .map(|e| {
1234                    let run_dir = e
1235                        .html_path
1236                        .as_ref()
1237                        .or(e.json_path.as_ref())
1238                        .and_then(|p| p.parent().map(PathBuf::from));
1239                    let config_val: Option<serde_json::Value> = run_dir
1240                        .and_then(|d| find_scan_config_in_dir(&d))
1241                        .and_then(|p| fs::read_to_string(&p).ok())
1242                        .and_then(|s| serde_json::from_str(&s).ok());
1243                    serde_json::json!({
1244                        "project_label": e.project_label,
1245                        "timestamp": fmt_pst(e.timestamp_utc),
1246                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1247                        "config": config_val,
1248                    })
1249                })
1250                .collect()
1251        };
1252        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1253    };
1254
1255    let template = ScanSetupTemplate {
1256        version: env!("CARGO_PKG_VERSION"),
1257        recent_scans_json,
1258        csp_nonce,
1259    };
1260    Html(
1261        template
1262            .render()
1263            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1264    )
1265}
1266
1267async fn healthz() -> &'static str {
1268    "ok"
1269}
1270
1271async fn chart_js_handler() -> impl IntoResponse {
1272    (
1273        [(
1274            header::CONTENT_TYPE,
1275            "application/javascript; charset=utf-8",
1276        )],
1277        CHART_JS,
1278    )
1279}
1280
1281#[derive(Debug, Deserialize)]
1282struct AnalyzeForm {
1283    path: String,
1284    git_repo: Option<String>,
1285    git_ref: Option<String>,
1286    mixed_line_policy: Option<MixedLinePolicy>,
1287    python_docstrings_as_comments: Option<String>,
1288    generated_file_detection: Option<String>,
1289    minified_file_detection: Option<String>,
1290    vendor_directory_detection: Option<String>,
1291    include_lockfiles: Option<String>,
1292    binary_file_behavior: Option<BinaryFileBehavior>,
1293    output_dir: Option<String>,
1294    report_title: Option<String>,
1295    generate_html: Option<String>,
1296    generate_pdf: Option<String>,
1297    include_globs: Option<String>,
1298    exclude_globs: Option<String>,
1299    submodule_breakdown: Option<String>,
1300}
1301
1302#[allow(clippy::struct_excessive_bools)]
1303#[derive(Debug, Serialize, Deserialize, Clone)]
1304struct ScanConfig {
1305    oxide_sloc_version: String,
1306    path: String,
1307    include_globs: String,
1308    exclude_globs: String,
1309    submodule_breakdown: bool,
1310    mixed_line_policy: String,
1311    python_docstrings_as_comments: bool,
1312    generated_file_detection: bool,
1313    minified_file_detection: bool,
1314    vendor_directory_detection: bool,
1315    include_lockfiles: bool,
1316    binary_file_behavior: String,
1317    output_dir: String,
1318    report_title: String,
1319    generate_html: bool,
1320    generate_pdf: bool,
1321}
1322
1323#[derive(Debug, Deserialize, Default)]
1324struct IndexQuery {
1325    path: Option<String>,
1326    include_globs: Option<String>,
1327    exclude_globs: Option<String>,
1328    submodule_breakdown: Option<String>,
1329    mixed_line_policy: Option<String>,
1330    python_docstrings_as_comments: Option<String>,
1331    generated_file_detection: Option<String>,
1332    minified_file_detection: Option<String>,
1333    vendor_directory_detection: Option<String>,
1334    include_lockfiles: Option<String>,
1335    binary_file_behavior: Option<String>,
1336    output_dir: Option<String>,
1337    report_title: Option<String>,
1338    generate_html: Option<String>,
1339    generate_pdf: Option<String>,
1340    prefilled: Option<String>,
1341    git_repo: Option<String>,
1342    git_ref: Option<String>,
1343}
1344
1345#[derive(Debug, Deserialize)]
1346struct PreviewQuery {
1347    path: Option<String>,
1348    include_globs: Option<String>,
1349    exclude_globs: Option<String>,
1350}
1351
1352#[cfg(feature = "native-dialog")]
1353#[derive(Debug, Deserialize)]
1354struct PickDirectoryQuery {
1355    kind: Option<String>,
1356    current: Option<String>,
1357}
1358
1359#[cfg(not(feature = "native-dialog"))]
1360#[derive(Debug, Deserialize)]
1361struct PickDirectoryQuery {}
1362
1363#[derive(Debug, Deserialize, Default)]
1364struct ArtifactQuery {
1365    download: Option<String>,
1366}
1367
1368#[cfg(feature = "native-dialog")]
1369#[derive(Debug, Serialize)]
1370struct PickDirectoryResponse {
1371    selected_path: Option<String>,
1372    cancelled: bool,
1373}
1374
1375#[cfg(feature = "native-dialog")]
1376async fn pick_directory_handler(
1377    State(state): State<AppState>,
1378    Query(query): Query<PickDirectoryQuery>,
1379) -> Response {
1380    if state.server_mode {
1381        return StatusCode::NOT_FOUND.into_response();
1382    }
1383
1384    let title = match query.kind.as_deref() {
1385        Some("output") => "Select output directory",
1386        Some("reports") => "Select folder containing saved reports",
1387        _ => "Select project directory",
1388    }
1389    .to_owned();
1390    let current = query.current.clone();
1391
1392    let picked = tokio::task::spawn_blocking(move || {
1393        // Windows: attach to the foreground thread so the dialog inherits focus,
1394        // and kick off a watcher that flashes the dialog once it appears.
1395        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1396        let _fg_tid = win_dialog_focus::attach_to_foreground();
1397        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1398        win_dialog_focus::flash_dialog_when_ready(title.clone());
1399
1400        let mut dialog = rfd::FileDialog::new().set_title(&title);
1401        if let Some(current) = current.as_deref() {
1402            let resolved = resolve_input_path(current);
1403            let seed = if resolved.is_dir() {
1404                Some(resolved)
1405            } else {
1406                resolved.parent().map(Path::to_path_buf)
1407            };
1408            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1409                dialog = dialog.set_directory(seed_dir);
1410            }
1411        }
1412        let result = dialog.pick_folder();
1413
1414        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1415        win_dialog_focus::detach_from_foreground(_fg_tid);
1416
1417        result
1418    })
1419    .await
1420    .unwrap_or(None);
1421
1422    Json(PickDirectoryResponse {
1423        selected_path: picked.as_ref().map(|p| display_path(p)),
1424        cancelled: picked.is_none(),
1425    })
1426    .into_response()
1427}
1428
1429#[cfg(not(feature = "native-dialog"))]
1430async fn pick_directory_handler(
1431    State(_state): State<AppState>,
1432    Query(_query): Query<PickDirectoryQuery>,
1433) -> Response {
1434    StatusCode::NOT_FOUND.into_response()
1435}
1436
1437#[cfg(feature = "native-dialog")]
1438async fn pick_file_handler(State(state): State<AppState>) -> Response {
1439    if state.server_mode {
1440        return StatusCode::NOT_FOUND.into_response();
1441    }
1442    let picked = tokio::task::spawn_blocking(|| {
1443        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1444        let _fg_tid = win_dialog_focus::attach_to_foreground();
1445        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1446        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1447
1448        let result = rfd::FileDialog::new()
1449            .set_title("Select HTML report")
1450            .add_filter("HTML report", &["html"])
1451            .pick_file();
1452
1453        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1454        win_dialog_focus::detach_from_foreground(_fg_tid);
1455
1456        result
1457    })
1458    .await
1459    .unwrap_or(None);
1460    Json(PickDirectoryResponse {
1461        selected_path: picked.as_ref().map(|p| display_path(p)),
1462        cancelled: picked.is_none(),
1463    })
1464    .into_response()
1465}
1466
1467#[cfg(not(feature = "native-dialog"))]
1468async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1469    StatusCode::NOT_FOUND.into_response()
1470}
1471
1472#[derive(Deserialize)]
1473struct LocateReportForm {
1474    file_path: String,
1475}
1476
1477/// Render a view-reports error page and return it as a `Response`.
1478fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
1479    let html = ErrorTemplate {
1480        message: message.into(),
1481        last_report_url: Some("/view-reports".to_string()),
1482        last_report_label: Some("View Reports".to_string()),
1483        csp_nonce: csp_nonce.to_owned(),
1484    }
1485    .render()
1486    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1487    Html(html).into_response()
1488}
1489
1490/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
1491fn registry_entry_from_run(
1492    run: &AnalysisRun,
1493    json_path: PathBuf,
1494    html_path: PathBuf,
1495) -> RegistryEntry {
1496    let project_label = run.input_roots.first().map_or_else(
1497        || "Unknown Project".to_string(),
1498        |r| sanitize_project_label(r),
1499    );
1500    RegistryEntry {
1501        run_id: run.tool.run_id.clone(),
1502        timestamp_utc: run.tool.timestamp_utc,
1503        project_label,
1504        input_roots: run.input_roots.clone(),
1505        json_path: Some(json_path),
1506        html_path: Some(html_path),
1507        pdf_path: None,
1508        summary: ScanSummarySnapshot {
1509            files_analyzed: run.summary_totals.files_analyzed,
1510            files_skipped: run.summary_totals.files_skipped,
1511            total_physical_lines: run.summary_totals.total_physical_lines,
1512            code_lines: run.summary_totals.code_lines,
1513            comment_lines: run.summary_totals.comment_lines,
1514            blank_lines: run.summary_totals.blank_lines,
1515            functions: run.summary_totals.functions,
1516            classes: run.summary_totals.classes,
1517            variables: run.summary_totals.variables,
1518            imports: run.summary_totals.imports,
1519        },
1520        git_branch: None,
1521        git_commit: None,
1522        git_author: None,
1523        git_tags: None,
1524        git_commit_date: None,
1525    }
1526}
1527
1528/// Validate the locate-report form: check extension, resolve the canonical path, enforce
1529/// server-mode root restriction, and extract the parent directory.
1530///
1531/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
1532#[allow(clippy::result_large_err)]
1533fn validate_locate_request(
1534    state: &AppState,
1535    file_path: &str,
1536    csp_nonce: &str,
1537) -> Result<(PathBuf, PathBuf), Response> {
1538    let file_ext = Path::new(file_path)
1539        .extension()
1540        .and_then(|e| e.to_str())
1541        .unwrap_or("")
1542        .to_ascii_lowercase();
1543    if file_ext != "html" {
1544        return Err(locate_report_error(
1545            "Only .html report files can be located via this form.",
1546            csp_nonce,
1547        ));
1548    }
1549    let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1550        Ok(p) => strip_unc_prefix(p),
1551        Err(_) => {
1552            return Err(locate_report_error(
1553                "Report file not found or path is invalid.",
1554                csp_nonce,
1555            ));
1556        }
1557    };
1558    if state.server_mode {
1559        let output_root = resolve_output_root(None);
1560        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1561        if !html_path.starts_with(&canonical_root) {
1562            return Err(locate_report_error(
1563                "Report file must be within the configured output directory.",
1564                csp_nonce,
1565            ));
1566        }
1567    }
1568    let parent = match html_path.parent() {
1569        Some(p) => p.to_path_buf(),
1570        None => {
1571            return Err(locate_report_error(
1572                "Report file has no parent directory.",
1573                csp_nonce,
1574            ));
1575        }
1576    };
1577    Ok((html_path, parent))
1578}
1579
1580/// Return a non-sensitive path hint for error messages (empty in server mode).
1581fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1582    if server_mode {
1583        String::new()
1584    } else {
1585        format!("\n\nFile: {}", path.display())
1586    }
1587}
1588
1589async fn locate_report_handler(
1590    State(state): State<AppState>,
1591    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1592    Form(form): Form<LocateReportForm>,
1593) -> impl IntoResponse {
1594    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1595        Ok(v) => v,
1596        Err(resp) => return resp,
1597    };
1598
1599    let json_candidate = parent.join("result.json");
1600    let mut reg = state.registry.lock().await;
1601    // Find an existing entry whose output directory matches the selected file's parent.
1602    let entry_idx = reg.entries.iter().position(|e| {
1603        let json_match = e
1604            .json_path
1605            .as_ref()
1606            .and_then(|p| p.parent())
1607            .is_some_and(|p| p == parent);
1608        let html_match = e
1609            .html_path
1610            .as_ref()
1611            .and_then(|p| p.parent())
1612            .is_some_and(|p| p == parent);
1613        json_match || html_match
1614    });
1615    if let Some(idx) = entry_idx {
1616        reg.entries[idx].html_path = Some(html_path);
1617        let _ = reg.save(&state.registry_path);
1618        return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1619    }
1620    // No match — attempt to build an entry from an adjacent result.json.
1621    if json_candidate.exists() {
1622        match read_json(&json_candidate) {
1623            Ok(run) => {
1624                let entry = registry_entry_from_run(&run, json_candidate, html_path);
1625                reg.add_entry(entry);
1626                let _ = reg.save(&state.registry_path);
1627                return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1628            }
1629            Err(e) => {
1630                let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1631                let err_detail = if state.server_mode {
1632                    String::new()
1633                } else {
1634                    format!("\n\nError: {e}")
1635                };
1636                return locate_report_error(
1637                    format!(
1638                        "Could not link this report.\n\nA 'result.json' was found but could not \
1639                         be parsed — it may have been saved by an older version of OxideSLOC. \
1640                         Re-running the analysis will create a fresh, compatible \
1641                         record.{file_hint}{err_detail}"
1642                    ),
1643                    &csp_nonce,
1644                );
1645            }
1646        }
1647    }
1648    drop(reg);
1649    let file_hint = locate_path_hint(state.server_mode, &html_path);
1650    locate_report_error(
1651        format!(
1652            "Could not link this report.\n\nNo matching scan record was found, and no \
1653             'result.json' was found in the same folder.{file_hint}"
1654        ),
1655        &csp_nonce,
1656    )
1657}
1658
1659#[derive(Deserialize)]
1660struct LocateReportsDirForm {
1661    folder_path: String,
1662}
1663
1664async fn locate_reports_dir_handler(
1665    State(state): State<AppState>,
1666    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1667    Form(form): Form<LocateReportsDirForm>,
1668) -> impl IntoResponse {
1669    if state.server_mode {
1670        return StatusCode::NOT_FOUND.into_response();
1671    }
1672    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
1673        Ok(p) => strip_unc_prefix(p),
1674        Err(_) => return locate_report_error("Folder not found or path is invalid.", &csp_nonce),
1675    };
1676    if !folder.is_dir() {
1677        return locate_report_error("Selected path is not a directory.", &csp_nonce);
1678    }
1679
1680    // Collect result.json candidates: the folder itself and one level of subdirectories.
1681    let mut candidates: Vec<PathBuf> = Vec::new();
1682    let top = folder.join("result.json");
1683    if top.exists() {
1684        candidates.push(top);
1685    }
1686    if let Ok(dir_entries) = fs::read_dir(&folder) {
1687        for entry in dir_entries.flatten() {
1688            let sub = entry.path();
1689            if sub.is_dir() {
1690                let j = sub.join("result.json");
1691                if j.exists() {
1692                    candidates.push(j);
1693                }
1694            }
1695        }
1696    }
1697
1698    if candidates.is_empty() {
1699        return locate_report_error(
1700            "No result.json files found in the selected folder or its subdirectories.",
1701            &csp_nonce,
1702        );
1703    }
1704
1705    let mut linked_count: usize = 0;
1706    let mut reg = state.registry.lock().await;
1707    for json_path in candidates {
1708        let parent = match json_path.parent() {
1709            Some(p) => p.to_path_buf(),
1710            None => continue,
1711        };
1712        // Skip if this directory is already registered.
1713        let already = reg.entries.iter().any(|e| {
1714            e.json_path
1715                .as_ref()
1716                .and_then(|p| p.parent())
1717                .is_some_and(|p| p == parent)
1718                || e.html_path
1719                    .as_ref()
1720                    .and_then(|p| p.parent())
1721                    .is_some_and(|p| p == parent)
1722        });
1723        if already {
1724            continue;
1725        }
1726        // Find the first .html file in the same directory.
1727        let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
1728            rd.flatten()
1729                .map(|e| e.path())
1730                .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
1731        });
1732        let run = match read_json(&json_path) {
1733            Ok(r) => r,
1734            Err(_) => continue,
1735        };
1736        let project_label = run.input_roots.first().map_or_else(
1737            || "Unknown Project".to_string(),
1738            |r| sanitize_project_label(r),
1739        );
1740        let entry = RegistryEntry {
1741            run_id: run.tool.run_id.clone(),
1742            timestamp_utc: run.tool.timestamp_utc,
1743            project_label,
1744            input_roots: run.input_roots.clone(),
1745            json_path: Some(json_path),
1746            html_path,
1747            pdf_path: None,
1748            summary: ScanSummarySnapshot {
1749                files_analyzed: run.summary_totals.files_analyzed,
1750                files_skipped: run.summary_totals.files_skipped,
1751                total_physical_lines: run.summary_totals.total_physical_lines,
1752                code_lines: run.summary_totals.code_lines,
1753                comment_lines: run.summary_totals.comment_lines,
1754                blank_lines: run.summary_totals.blank_lines,
1755                functions: run.summary_totals.functions,
1756                classes: run.summary_totals.classes,
1757                variables: run.summary_totals.variables,
1758                imports: run.summary_totals.imports,
1759            },
1760            git_branch: run.git_branch.clone(),
1761            git_commit: run.git_commit_short.clone(),
1762            git_author: run.git_commit_author.clone(),
1763            git_tags: run.git_tags.clone(),
1764            git_commit_date: run.git_commit_date.clone(),
1765        };
1766        reg.add_entry(entry);
1767        linked_count += 1;
1768    }
1769    let _ = reg.save(&state.registry_path);
1770    drop(reg);
1771
1772    if linked_count == 0 {
1773        return locate_report_error(
1774            "No new reports were loaded. The selected folder may already be fully indexed, \
1775             or the result.json files could not be parsed.",
1776            &csp_nonce,
1777        );
1778    }
1779    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
1780}
1781
1782#[derive(Debug, Deserialize)]
1783struct OpenPathQuery {
1784    path: Option<String>,
1785}
1786
1787async fn open_path_handler(
1788    State(state): State<AppState>,
1789    Query(query): Query<OpenPathQuery>,
1790) -> impl IntoResponse {
1791    if state.server_mode {
1792        return StatusCode::NOT_FOUND.into_response();
1793    }
1794    let raw = match query.path.as_deref() {
1795        Some(p) if !p.is_empty() => p,
1796        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
1797    };
1798
1799    let Ok(canonical) = fs::canonicalize(raw) else {
1800        return (StatusCode::BAD_REQUEST, "path not found").into_response();
1801    };
1802
1803    // Must be a directory (or a file whose parent directory we open).
1804    let target = if canonical.is_file() {
1805        match canonical.parent() {
1806            Some(p) => p.to_path_buf(),
1807            None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
1808        }
1809    } else if canonical.is_dir() {
1810        canonical
1811    } else {
1812        // Block special devices, pipes, sockets, etc.
1813        return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response();
1814    };
1815
1816    #[cfg(target_os = "windows")]
1817    let _ = std::process::Command::new("explorer.exe")
1818        .arg(&target)
1819        .stdout(Stdio::null())
1820        .stderr(Stdio::null())
1821        .spawn();
1822    #[cfg(target_os = "macos")]
1823    let _ = std::process::Command::new("open")
1824        .arg(&target)
1825        .stdout(Stdio::null())
1826        .stderr(Stdio::null())
1827        .spawn();
1828    #[cfg(target_os = "linux")]
1829    let _ = std::process::Command::new("xdg-open")
1830        .arg(&target)
1831        .stdout(Stdio::null())
1832        .stderr(Stdio::null())
1833        .spawn();
1834
1835    (StatusCode::OK, "ok").into_response()
1836}
1837
1838async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
1839    let (content_type, bytes): (&'static str, &'static [u8]) =
1840        match (folder.as_str(), file.as_str()) {
1841            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
1842            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
1843            ("icons", "c.png") => ("image/png", IMG_ICON_C),
1844            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
1845            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
1846            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
1847            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
1848            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
1849            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
1850            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
1851            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
1852            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
1853            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
1854            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
1855            ("icons", "r.png") => ("image/png", IMG_ICON_R),
1856            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
1857            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
1858            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
1859            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
1860            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
1861            _ => return StatusCode::NOT_FOUND.into_response(),
1862        };
1863    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
1864}
1865
1866async fn preview_handler(
1867    State(state): State<AppState>,
1868    Query(query): Query<PreviewQuery>,
1869) -> impl IntoResponse {
1870    let raw_path = query
1871        .path
1872        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
1873    let resolved = resolve_input_path(&raw_path);
1874
1875    if state.server_mode {
1876        let config = &state.base_config;
1877        if config.discovery.allowed_scan_roots.is_empty() {
1878            return Html(
1879                r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
1880            );
1881        }
1882        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
1883        let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1884            fs::canonicalize(root)
1885                .ok()
1886                .is_some_and(|r| canonical.starts_with(&r))
1887        });
1888        if !allowed {
1889            return Html(
1890                r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
1891            );
1892        }
1893    }
1894
1895    let include_patterns = split_patterns(query.include_globs.as_deref());
1896    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
1897
1898    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
1899        Ok(html) => Html(html),
1900        Err(err) => Html(format!(
1901            r#"<div class="preview-error">Preview failed: {}</div>"#,
1902            escape_html(&err.to_string())
1903        )),
1904    }
1905}
1906
1907/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
1908#[allow(clippy::result_large_err)]
1909fn validate_server_scan_path(
1910    config: &sloc_config::AppConfig,
1911    resolved_path: &Path,
1912    csp_nonce: &str,
1913) -> Result<(), Response> {
1914    if config.discovery.allowed_scan_roots.is_empty() {
1915        let template = ErrorTemplate {
1916            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
1917                      Set allowed_scan_roots in the server config to permit scanning."
1918                .to_string(),
1919            last_report_url: None,
1920            last_report_label: None,
1921            csp_nonce: csp_nonce.to_owned(),
1922        };
1923        return Err((
1924            StatusCode::FORBIDDEN,
1925            Html(
1926                template
1927                    .render()
1928                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
1929            ),
1930        )
1931            .into_response());
1932    }
1933    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
1934    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1935        fs::canonicalize(root)
1936            .ok()
1937            .is_some_and(|r| canonical.starts_with(&r))
1938    });
1939    if !allowed {
1940        tracing::warn!(event = "path_rejected", path = %canonical.display(),
1941            "Scan path not in allowed_scan_roots");
1942        let template = ErrorTemplate {
1943            message: "The requested path is not within an allowed scan directory.".to_string(),
1944            last_report_url: None,
1945            last_report_label: None,
1946            csp_nonce: csp_nonce.to_owned(),
1947        };
1948        return Err((
1949            StatusCode::FORBIDDEN,
1950            Html(
1951                template
1952                    .render()
1953                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
1954            ),
1955        )
1956            .into_response());
1957    }
1958    Ok(())
1959}
1960
1961/// Exclude the output directory from scanning so artifacts don't pollute counts.
1962fn apply_output_dir_exclusions(
1963    config: &mut sloc_config::AppConfig,
1964    project_path: &str,
1965    raw_output_dir: &str,
1966) {
1967    let project_root = resolve_input_path(project_path);
1968    let raw_out = raw_output_dir.trim();
1969    let resolved_out = if raw_out.is_empty() {
1970        project_root.join("sloc")
1971    } else if Path::new(raw_out).is_absolute() {
1972        PathBuf::from(raw_out)
1973    } else {
1974        workspace_root().join(raw_out)
1975    };
1976    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
1977        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
1978            let dir = first.to_string();
1979            if !config.discovery.excluded_directories.contains(&dir) {
1980                config.discovery.excluded_directories.push(dir);
1981            }
1982        }
1983    }
1984    if !config
1985        .discovery
1986        .excluded_directories
1987        .iter()
1988        .any(|d| d == "sloc")
1989    {
1990        config
1991            .discovery
1992            .excluded_directories
1993            .push("sloc".to_string());
1994    }
1995}
1996
1997/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
1998const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
1999    ScanSummarySnapshot {
2000        files_analyzed: run.summary_totals.files_analyzed,
2001        files_skipped: run.summary_totals.files_skipped,
2002        total_physical_lines: run.summary_totals.total_physical_lines,
2003        code_lines: run.summary_totals.code_lines,
2004        comment_lines: run.summary_totals.comment_lines,
2005        blank_lines: run.summary_totals.blank_lines,
2006        functions: run.summary_totals.functions,
2007        classes: run.summary_totals.classes,
2008        variables: run.summary_totals.variables,
2009        imports: run.summary_totals.imports,
2010    }
2011}
2012
2013/// Build the `RegistryEntry` for the just-completed scan run.
2014pub(crate) fn build_run_registry_entry(
2015    run: &AnalysisRun,
2016    run_id: &str,
2017    project_label: &str,
2018    artifacts: &RunArtifacts,
2019) -> RegistryEntry {
2020    RegistryEntry {
2021        run_id: run_id.to_owned(),
2022        timestamp_utc: run.tool.timestamp_utc,
2023        project_label: project_label.to_owned(),
2024        input_roots: run.input_roots.clone(),
2025        json_path: artifacts.json_path.clone(),
2026        html_path: artifacts.html_path.clone(),
2027        pdf_path: artifacts.pdf_path.clone(),
2028        summary: summary_snapshot_from_run(run),
2029        git_branch: run.git_branch.clone(),
2030        git_commit: run.git_commit_short.clone(),
2031        git_author: run.git_commit_author.clone(),
2032        git_tags: run.git_tags.clone(),
2033        git_commit_date: run.git_commit_date.clone(),
2034    }
2035}
2036
2037/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
2038fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
2039    if let Some(policy) = form.mixed_line_policy {
2040        config.analysis.mixed_line_policy = policy;
2041    }
2042    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
2043    config.analysis.generated_file_detection =
2044        form.generated_file_detection.as_deref() != Some("disabled");
2045    config.analysis.minified_file_detection =
2046        form.minified_file_detection.as_deref() != Some("disabled");
2047    config.analysis.vendor_directory_detection =
2048        form.vendor_directory_detection.as_deref() != Some("disabled");
2049    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
2050    if let Some(binary_behavior) = form.binary_file_behavior {
2051        config.analysis.binary_file_behavior = binary_behavior;
2052    }
2053    if let Some(report_title) = form.report_title.as_deref() {
2054        let trimmed = report_title.trim();
2055        if !trimmed.is_empty() {
2056            config.reporting.report_title = trimmed.to_string();
2057        }
2058    }
2059    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
2060    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
2061    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
2062}
2063
2064/// Fire-and-forget: generate the PDF in a background task if one is pending.
2065fn spawn_pdf_background(pending_pdf: PendingPdf) {
2066    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
2067        tokio::spawn(async move {
2068            let result = tokio::task::spawn_blocking(move || {
2069                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
2070                if cleanup_src {
2071                    let _ = fs::remove_file(&pdf_src);
2072                }
2073                r
2074            })
2075            .await;
2076            match result {
2077                Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
2078                Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
2079                Ok(Ok(())) => {}
2080            }
2081        });
2082    }
2083}
2084
2085/// Sum the code lines added in this comparison (new + grown files).
2086fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2087    cmp.file_deltas
2088        .iter()
2089        .map(|f| match f.status {
2090            FileChangeStatus::Added => f.current_code,
2091            FileChangeStatus::Modified => f.code_delta.max(0),
2092            _ => 0,
2093        })
2094        .sum()
2095}
2096
2097/// Sum the code lines removed in this comparison (deleted + shrunk files).
2098fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2099    cmp.file_deltas
2100        .iter()
2101        .map(|f| match f.status {
2102            FileChangeStatus::Removed => f.baseline_code,
2103            FileChangeStatus::Modified => (-f.code_delta).max(0),
2104            _ => 0,
2105        })
2106        .sum()
2107}
2108
2109/// Build one `SubmoduleRow`, optionally generating and persisting a sub-report HTML file.
2110fn build_submodule_row(
2111    s: &sloc_core::SubmoduleSummary,
2112    run: &AnalysisRun,
2113    run_id: &str,
2114    run_dir: &Path,
2115    generate_html: bool,
2116) -> SubmoduleRow {
2117    let safe = sanitize_project_label(&s.name);
2118    let artifact_key = format!("sub_{safe}");
2119    let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
2120        let parent_path = run
2121            .input_roots
2122            .first()
2123            .map_or("", std::string::String::as_str);
2124        let sub_run = build_sub_run(run, s, parent_path);
2125        render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
2126            let path = run_dir.join(format!("{artifact_key}.html"));
2127            if fs::write(&path, sub_html.as_bytes()).is_ok() {
2128                Some(format!("/runs/{run_id}/{artifact_key}"))
2129            } else {
2130                None
2131            }
2132        })
2133    } else {
2134        None
2135    };
2136    SubmoduleRow {
2137        name: s.name.clone(),
2138        relative_path: s.relative_path.clone(),
2139        files_analyzed: s.files_analyzed,
2140        code_lines: s.code_lines,
2141        comment_lines: s.comment_lines,
2142        blank_lines: s.blank_lines,
2143        total_physical_lines: s.total_physical_lines,
2144        html_url,
2145    }
2146}
2147
2148// Immediately returns a wait page and runs the analysis in a background tokio task.
2149// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
2150#[allow(clippy::too_many_lines)]
2151#[allow(clippy::similar_names)]
2152async fn analyze_handler(
2153    State(state): State<AppState>,
2154    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2155    Form(form): Form<AnalyzeForm>,
2156) -> impl IntoResponse {
2157    let Ok(_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
2158        let template = ErrorTemplate {
2159            message: "Server is busy — too many concurrent analyses. Please try again in a moment."
2160                .to_string(),
2161            last_report_url: None,
2162            last_report_label: None,
2163            csp_nonce: csp_nonce.clone(),
2164        };
2165        return (
2166            StatusCode::SERVICE_UNAVAILABLE,
2167            Html(
2168                template
2169                    .render()
2170                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
2171            ),
2172        )
2173            .into_response();
2174    };
2175
2176    let mut config = state.base_config.clone();
2177
2178    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
2179    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
2180    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
2181
2182    if !is_git_mode {
2183        let resolved_path = resolve_input_path(&form.path);
2184        if state.server_mode {
2185            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
2186                return resp;
2187            }
2188        }
2189        config.discovery.root_paths = vec![resolved_path];
2190    }
2191
2192    apply_form_to_config(&mut config, &form);
2193    apply_output_dir_exclusions(
2194        &mut config,
2195        &form.path,
2196        form.output_dir.as_deref().unwrap_or(""),
2197    );
2198
2199    // Generate a wait_id now (before spawning) so the client can poll for status.
2200    let wait_id = uuid::Uuid::new_v4().to_string();
2201    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
2202
2203    // Clone everything the background task needs before moving into the spawn.
2204    let project_path_bg = form.path.clone();
2205    let output_dir_bg = form.output_dir.clone();
2206    let git_repo_bg = form.git_repo.clone().filter(|s| !s.is_empty());
2207    let git_ref_bg = form.git_ref.clone().filter(|s| !s.is_empty());
2208    let generate_html_bg = form.generate_html.is_some();
2209    let generate_pdf_bg = form.generate_pdf.is_some();
2210    let clones_dir = state.git_clones_dir.clone();
2211    let wait_id_bg = wait_id.clone();
2212    let state_bg = state.clone();
2213
2214    {
2215        let mut runs = state.async_runs.lock().await;
2216        runs.insert(
2217            wait_id.clone(),
2218            AsyncRunState::Running {
2219                started_at: std::time::Instant::now(),
2220            },
2221        );
2222    }
2223
2224    tokio::spawn(async move {
2225        // Hold the permit for the lifetime of the background task.
2226        let _permit = _permit;
2227
2228        // Clone before moving into spawn_blocking so we can use them again afterwards.
2229        let git_repo_sb = git_repo_bg.clone();
2230        let git_ref_sb = git_ref_bg.clone();
2231        let analysis_result =
2232            tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
2233                if let (Some(repo), Some(refname)) = (&git_repo_sb, &git_ref_sb) {
2234                    let dest = git_clone_dest(repo, &clones_dir);
2235                    sloc_git::clone_or_fetch(repo, &dest)?;
2236                    let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
2237                    sloc_git::create_worktree(&dest, refname, &wt)?;
2238                    config.discovery.root_paths = vec![wt.clone()];
2239                    let run = analyze(&config, "serve");
2240                    let _ = sloc_git::destroy_worktree(&dest, &wt);
2241                    let mut run = run?;
2242                    if run.git_branch.is_none() {
2243                        run.git_branch = Some(refname.clone());
2244                    }
2245                    let html = render_html(&run)?;
2246                    return Ok((run, html));
2247                }
2248                let run = analyze(&config, "serve")?;
2249                let html = render_html(&run)?;
2250                Ok((run, html))
2251            })
2252            .await
2253            .map_err(|err| anyhow::anyhow!(err.to_string()))
2254            .and_then(|result| result);
2255
2256        let (run, report_html) = match analysis_result {
2257            Ok(v) => v,
2258            Err(err) => {
2259                eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
2260                let mut runs = state_bg.async_runs.lock().await;
2261                runs.insert(
2262                    wait_id_bg.clone(),
2263                    AsyncRunState::Failed {
2264                        message: "Analysis failed. Check that the path exists and is readable."
2265                            .to_string(),
2266                    },
2267                );
2268                return;
2269            }
2270        };
2271
2272        let run_id = run.tool.run_id.clone();
2273        tracing::info!(event = "scan_complete", run_id = %run_id,
2274            path = %project_path_bg, files = run.summary_totals.files_analyzed,
2275            "Analysis finished");
2276
2277        let prev_entry: Option<RegistryEntry> = {
2278            let reg = state_bg.registry.lock().await;
2279            reg.entries_for_roots(&run.input_roots)
2280                .into_iter()
2281                .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
2282                .cloned()
2283        };
2284
2285        let scan_delta = prev_entry.as_ref().and_then(|prev| {
2286            prev.json_path
2287                .as_ref()
2288                .and_then(|p| read_json(p).ok())
2289                .map(|prev_run| compute_delta(&prev_run, &run))
2290        });
2291        let prev_scan_count: usize = {
2292            let reg = state_bg.registry.lock().await;
2293            reg.entries_for_roots(&run.input_roots)
2294                .iter()
2295                .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
2296                .count()
2297        };
2298
2299        let output_root = resolve_output_root(output_dir_bg.as_deref());
2300
2301        let project_label = if let (Some(repo), Some(refname)) = (
2302            git_repo_bg.as_deref().filter(|s| !s.is_empty()),
2303            git_ref_bg.as_deref().filter(|s| !s.is_empty()),
2304        ) {
2305            let repo_name = repo
2306                .trim_end_matches('/')
2307                .trim_end_matches(".git")
2308                .rsplit('/')
2309                .next()
2310                .unwrap_or("repo");
2311            sanitize_project_label(&format!("{repo_name}_{refname}"))
2312        } else {
2313            sanitize_project_label(&project_path_bg)
2314        };
2315        let run_dir = output_root.join(format!("{project_label}_{run_id}"));
2316        let file_stem = {
2317            let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
2318            if commit.is_empty() {
2319                project_label.clone()
2320            } else {
2321                format!("{project_label}_{commit}")
2322            }
2323        };
2324
2325        let result_context = RunResultContext {
2326            prev_entry: prev_entry.clone(),
2327            prev_scan_count,
2328            project_path: project_path_bg.clone(),
2329        };
2330
2331        let artifact_result = persist_run_artifacts(
2332            &run,
2333            &report_html,
2334            &run_dir,
2335            true,
2336            generate_html_bg,
2337            generate_pdf_bg,
2338            &run.effective_configuration.reporting.report_title,
2339            &file_stem,
2340            result_context,
2341        );
2342
2343        let (artifacts, pending_pdf) = match artifact_result {
2344            Ok(v) => v,
2345            Err(err) => {
2346                eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
2347                let mut runs = state_bg.async_runs.lock().await;
2348                runs.insert(
2349                    wait_id_bg.clone(),
2350                    AsyncRunState::Failed {
2351                        message: "Failed to save report artifacts. Check available disk space."
2352                            .to_string(),
2353                    },
2354                );
2355                return;
2356            }
2357        };
2358
2359        {
2360            let mut map = state_bg.artifacts.lock().await;
2361            map.insert(run_id.clone(), artifacts.clone());
2362        }
2363
2364        {
2365            let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
2366            let mut reg = state_bg.registry.lock().await;
2367            reg.add_entry(entry);
2368            let _ = reg.save(&state_bg.registry_path);
2369        }
2370
2371        if let Some(ref cfg_path) = artifacts.scan_config_path {
2372            let policy_str =
2373                serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
2374                    .ok()
2375                    .and_then(|v| v.as_str().map(String::from))
2376                    .unwrap_or_else(|| "code_only".to_string());
2377            let behavior_str =
2378                serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
2379                    .ok()
2380                    .and_then(|v| v.as_str().map(String::from))
2381                    .unwrap_or_else(|| "skip".to_string());
2382            let scan_cfg = ScanConfig {
2383                oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2384                path: project_path_bg.clone(),
2385                include_globs: run
2386                    .effective_configuration
2387                    .discovery
2388                    .include_globs
2389                    .join("\n"),
2390                exclude_globs: run
2391                    .effective_configuration
2392                    .discovery
2393                    .exclude_globs
2394                    .join("\n"),
2395                submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
2396                mixed_line_policy: policy_str,
2397                python_docstrings_as_comments: run
2398                    .effective_configuration
2399                    .analysis
2400                    .python_docstrings_as_comments,
2401                generated_file_detection: run
2402                    .effective_configuration
2403                    .analysis
2404                    .generated_file_detection,
2405                minified_file_detection: run
2406                    .effective_configuration
2407                    .analysis
2408                    .minified_file_detection,
2409                vendor_directory_detection: run
2410                    .effective_configuration
2411                    .analysis
2412                    .vendor_directory_detection,
2413                include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
2414                binary_file_behavior: behavior_str,
2415                output_dir: output_dir_bg.clone().unwrap_or_default(),
2416                report_title: run.effective_configuration.reporting.report_title.clone(),
2417                generate_html: generate_html_bg,
2418                generate_pdf: generate_pdf_bg,
2419            };
2420            if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
2421                let _ = std::fs::write(cfg_path, json);
2422            }
2423        }
2424
2425        spawn_pdf_background(pending_pdf);
2426
2427        // Mark complete — client is now polling and will be redirected to /runs/{run_id}/result.
2428        let mut runs = state_bg.async_runs.lock().await;
2429        runs.insert(
2430            wait_id_bg.clone(),
2431            AsyncRunState::Complete {
2432                run_id: run_id.clone(),
2433            },
2434        );
2435        drop(runs);
2436
2437        // Submodule sub-reports are rendered synchronously above inside background task.
2438        let _ = scan_delta;
2439    });
2440
2441    let template = ScanWaitTemplate {
2442        version: env!("CARGO_PKG_VERSION"),
2443        wait_id_json,
2444        project_path: form.path.clone(),
2445        csp_nonce,
2446    };
2447    let html = template
2448        .render()
2449        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
2450    let mut response = Html(html).into_response();
2451    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
2452        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
2453            response.headers_mut().insert(name, val);
2454        }
2455    }
2456    response
2457}
2458
2459// ── Async scan status + result handlers ──────────────────────────────────────
2460
2461#[derive(Serialize)]
2462#[serde(tag = "state", rename_all = "snake_case")]
2463enum AsyncRunStatusResponse {
2464    Running { elapsed_secs: u64 },
2465    Complete { run_id: String },
2466    Failed { message: String },
2467}
2468
2469async fn async_run_status_handler(
2470    State(state): State<AppState>,
2471    AxumPath(wait_id): AxumPath<String>,
2472) -> Response {
2473    // wait_id comes from our own UUID generator; reject any structurally malformed value.
2474    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
2475        return StatusCode::BAD_REQUEST.into_response();
2476    }
2477    let run_state = {
2478        let runs = state.async_runs.lock().await;
2479        runs.get(&wait_id).cloned()
2480    };
2481    match run_state {
2482        None => StatusCode::NOT_FOUND.into_response(),
2483        Some(AsyncRunState::Running { started_at }) => {
2484            // Treat runs older than 2 h as timed out (analysis should finish well under that).
2485            if started_at.elapsed() > std::time::Duration::from_secs(7200) {
2486                let mut runs = state.async_runs.lock().await;
2487                runs.insert(
2488                    wait_id,
2489                    AsyncRunState::Failed {
2490                        message: "Analysis timed out after 2 hours.".to_string(),
2491                    },
2492                );
2493                return Json(AsyncRunStatusResponse::Failed {
2494                    message: "Analysis timed out after 2 hours.".to_string(),
2495                })
2496                .into_response();
2497            }
2498            Json(AsyncRunStatusResponse::Running {
2499                elapsed_secs: started_at.elapsed().as_secs(),
2500            })
2501            .into_response()
2502        }
2503        Some(AsyncRunState::Complete { run_id }) => {
2504            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
2505        }
2506        Some(AsyncRunState::Failed { message }) => {
2507            Json(AsyncRunStatusResponse::Failed { message }).into_response()
2508        }
2509    }
2510}
2511
2512async fn async_run_result_handler(
2513    State(state): State<AppState>,
2514    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2515    AxumPath(run_id): AxumPath<String>,
2516) -> Response {
2517    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
2518        return StatusCode::BAD_REQUEST.into_response();
2519    }
2520
2521    let artifacts = {
2522        let map = state.artifacts.lock().await;
2523        map.get(&run_id).cloned()
2524    };
2525    let artifacts = if let Some(a) = artifacts {
2526        a
2527    } else {
2528        let reg = state.registry.lock().await;
2529        if let Some(entry) = reg.find_by_run_id(&run_id) {
2530            recover_artifacts_from_registry(entry)
2531        } else {
2532            let html = ErrorTemplate {
2533                message: format!(
2534                    "Report not found. Run ID {} is not in the scan history.",
2535                    &run_id[..run_id.len().min(8)]
2536                ),
2537                last_report_url: Some("/view-reports".to_string()),
2538                last_report_label: Some("View Reports".to_string()),
2539                csp_nonce: csp_nonce.clone(),
2540            }
2541            .render()
2542            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
2543            return (StatusCode::NOT_FOUND, Html(html)).into_response();
2544        }
2545    };
2546
2547    let json_path = match &artifacts.json_path {
2548        Some(p) => p.clone(),
2549        None => {
2550            let html = ErrorTemplate {
2551                message: "JSON result was not saved for this run.".to_string(),
2552                last_report_url: Some("/view-reports".to_string()),
2553                last_report_label: Some("View Reports".to_string()),
2554                csp_nonce: csp_nonce.clone(),
2555            }
2556            .render()
2557            .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
2558            return (StatusCode::NOT_FOUND, Html(html)).into_response();
2559        }
2560    };
2561
2562    let run = match read_json(&json_path) {
2563        Ok(r) => r,
2564        Err(e) => {
2565            let html = ErrorTemplate {
2566                message: format!("Could not load scan result: {e}"),
2567                last_report_url: Some("/view-reports".to_string()),
2568                last_report_label: Some("View Reports".to_string()),
2569                csp_nonce: csp_nonce.clone(),
2570            }
2571            .render()
2572            .unwrap_or_else(|_| "<pre>Load error.</pre>".to_string());
2573            return (StatusCode::INTERNAL_SERVER_ERROR, Html(html)).into_response();
2574        }
2575    };
2576
2577    render_result_page(&run, &artifacts, &run_id, &csp_nonce)
2578}
2579
2580#[allow(clippy::too_many_lines)]
2581fn render_result_page(
2582    run: &AnalysisRun,
2583    artifacts: &RunArtifacts,
2584    run_id: &str,
2585    csp_nonce: &str,
2586) -> Response {
2587    let ctx = &artifacts.result_context;
2588    let prev_entry = &ctx.prev_entry;
2589    let prev_scan_count = ctx.prev_scan_count;
2590    let project_path = &ctx.project_path;
2591
2592    let scan_delta = prev_entry.as_ref().and_then(|prev| {
2593        prev.json_path
2594            .as_ref()
2595            .and_then(|p| read_json(p).ok())
2596            .map(|prev_run| compute_delta(&prev_run, run))
2597    });
2598
2599    let language_rows = run
2600        .totals_by_language
2601        .iter()
2602        .map(|row| LanguageSummaryRow {
2603            language: row.language.display_name().to_string(),
2604            files: row.files,
2605            physical: row.total_physical_lines,
2606            code: row.code_lines,
2607            comments: row.comment_lines,
2608            blank: row.blank_lines,
2609            mixed: row.mixed_lines_separate,
2610            functions: row.functions,
2611            classes: row.classes,
2612            variables: row.variables,
2613            imports: row.imports,
2614        })
2615        .collect::<Vec<_>>();
2616
2617    let files_analyzed = run.per_file_records.len() as u64;
2618    let files_skipped = run.skipped_file_records.len() as u64;
2619    let physical_lines = language_rows.iter().map(|r| r.physical).sum::<u64>();
2620    let code_lines = language_rows.iter().map(|r| r.code).sum::<u64>();
2621    let comment_lines = language_rows.iter().map(|r| r.comments).sum::<u64>();
2622    let blank_lines = language_rows.iter().map(|r| r.blank).sum::<u64>();
2623    let mixed_lines = language_rows.iter().map(|r| r.mixed).sum::<u64>();
2624    let functions = language_rows.iter().map(|r| r.functions).sum::<u64>();
2625    let classes = language_rows.iter().map(|r| r.classes).sum::<u64>();
2626    let variables = language_rows.iter().map(|r| r.variables).sum::<u64>();
2627    let imports = language_rows.iter().map(|r| r.imports).sum::<u64>();
2628
2629    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
2630    let prev_fa = prev_sum.map(|s| s.files_analyzed);
2631    let prev_fs = prev_sum.map(|s| s.files_skipped);
2632    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
2633    let prev_cl = prev_sum.map(|s| s.code_lines);
2634    let prev_cml = prev_sum.map(|s| s.comment_lines);
2635    let prev_bl = prev_sum.map(|s| s.blank_lines);
2636    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
2637    let prev_fa_str = fmt_prev(prev_fa);
2638    let prev_fs_str = fmt_prev(prev_fs);
2639    let prev_pl_str = fmt_prev(prev_pl);
2640    let prev_cl_str = fmt_prev(prev_cl);
2641    let prev_cml_str = fmt_prev(prev_cml);
2642    let prev_bl_str = fmt_prev(prev_bl);
2643    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
2644    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
2645    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
2646    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
2647    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
2648    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
2649    let delta_fa_class = delta_fa_class.to_string();
2650    let delta_fs_class = delta_fs_class.to_string();
2651    let delta_pl_class = delta_pl_class.to_string();
2652    let delta_cl_class = delta_cl_class.to_string();
2653    let delta_cml_class = delta_cml_class.to_string();
2654    let delta_bl_class = delta_bl_class.to_string();
2655
2656    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
2657    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
2658    let (delta_lines_net_str, delta_lines_net_class) =
2659        match (delta_lines_added, delta_lines_removed) {
2660            (Some(a), Some(r)) => {
2661                let net = a - r;
2662                (fmt_delta(net), delta_class(net).to_string())
2663            }
2664            _ => ("—".to_string(), "na".to_string()),
2665        };
2666
2667    let run_dir = artifacts.output_dir.clone();
2668    let git_branch = run.git_branch.clone();
2669    let git_commit = run.git_commit_short.clone();
2670    let git_author = run.git_commit_author.clone();
2671
2672    let template = ResultTemplate {
2673        version: env!("CARGO_PKG_VERSION"),
2674        report_title: run.effective_configuration.reporting.report_title.clone(),
2675        project_path: project_path.clone(),
2676        output_dir: display_path(&artifacts.output_dir),
2677        run_id: run_id.to_owned(),
2678        files_analyzed,
2679        files_skipped,
2680        physical_lines,
2681        code_lines,
2682        comment_lines,
2683        blank_lines,
2684        mixed_lines,
2685        functions,
2686        classes,
2687        variables,
2688        imports,
2689        html_url: artifacts
2690            .html_path
2691            .as_ref()
2692            .map(|_| format!("/runs/{run_id}/html")),
2693        pdf_url: artifacts
2694            .pdf_path
2695            .as_ref()
2696            .map(|_| format!("/runs/{run_id}/pdf")),
2697        json_url: artifacts
2698            .json_path
2699            .as_ref()
2700            .map(|_| format!("/runs/{run_id}/json")),
2701        html_download_url: artifacts
2702            .html_path
2703            .as_ref()
2704            .map(|_| format!("/runs/{run_id}/html?download=1")),
2705        pdf_download_url: artifacts
2706            .pdf_path
2707            .as_ref()
2708            .map(|_| format!("/runs/{run_id}/pdf?download=1")),
2709        json_download_url: artifacts
2710            .json_path
2711            .as_ref()
2712            .map(|_| format!("/runs/{run_id}/json?download=1")),
2713        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
2714        pdf_path: artifacts.pdf_path.as_ref().map(|p| display_path(p)),
2715        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
2716        language_rows,
2717        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
2718        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_pst(e.timestamp_utc)),
2719        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
2720        prev_fa_str,
2721        prev_fs_str,
2722        prev_pl_str,
2723        prev_cl_str,
2724        prev_cml_str,
2725        prev_bl_str,
2726        delta_fa_str,
2727        delta_fa_class,
2728        delta_fs_str,
2729        delta_fs_class,
2730        delta_pl_str,
2731        delta_pl_class,
2732        delta_cl_str,
2733        delta_cl_class,
2734        delta_cml_str,
2735        delta_cml_class,
2736        delta_bl_str,
2737        delta_bl_class,
2738        delta_lines_added,
2739        delta_lines_removed,
2740        delta_lines_net_str,
2741        delta_lines_net_class,
2742        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
2743        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
2744        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
2745        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
2746        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
2747            d.file_deltas
2748                .iter()
2749                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
2750                .map(|f| {
2751                    #[allow(clippy::cast_sign_loss)]
2752                    let n = f.current_code as u64;
2753                    n
2754                })
2755                .sum()
2756        }),
2757        git_branch: git_branch.clone(),
2758        git_commit: git_commit.clone(),
2759        git_author: git_author.clone(),
2760        current_scan_number: prev_scan_count + 1,
2761        prev_scan_count,
2762        submodule_rows: run
2763            .submodule_summaries
2764            .iter()
2765            .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
2766            .collect(),
2767        pdf_generating: artifacts
2768            .pdf_path
2769            .as_ref()
2770            .map(|p| !p.exists())
2771            .unwrap_or(false),
2772        scan_config_url: format!("/runs/{run_id}/scan-config"),
2773        lang_chart_json: {
2774            let entries: Vec<String> = run
2775                .totals_by_language
2776                .iter()
2777                .take(12)
2778                .map(|l| {
2779                    let name = l
2780                        .language
2781                        .display_name()
2782                        .replace('\\', "\\\\")
2783                        .replace('"', "\\\"");
2784                    format!(
2785                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{}}}"#,
2786                        name, l.code_lines, l.comment_lines, l.blank_lines,
2787                    )
2788                })
2789                .collect();
2790            format!("[{}]", entries.join(","))
2791        },
2792        csp_nonce: csp_nonce.to_owned(),
2793    };
2794
2795    Html(
2796        template
2797            .render()
2798            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2799    )
2800    .into_response()
2801}
2802
2803fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
2804    let slug: String = report_title
2805        .chars()
2806        .map(|c| {
2807            if c.is_alphanumeric() || c == '-' {
2808                c.to_ascii_lowercase()
2809            } else {
2810                '_'
2811            }
2812        })
2813        .collect::<String>()
2814        .split('_')
2815        .filter(|s| !s.is_empty())
2816        .collect::<Vec<_>>()
2817        .join("_");
2818
2819    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
2820
2821    if slug.is_empty() {
2822        format!("report_{short_id}.pdf")
2823    } else {
2824        format!("{slug}_{short_id}.pdf")
2825    }
2826}
2827
2828/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
2829/// Clients poll this to update the button state without page reloads.
2830async fn pdf_status_handler(
2831    State(state): State<AppState>,
2832    AxumPath(run_id): AxumPath<String>,
2833) -> Response {
2834    let pdf_path = {
2835        let registry = state.artifacts.lock().await;
2836        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
2837    };
2838    let pdf_path = if pdf_path.is_some() {
2839        pdf_path
2840    } else {
2841        let reg = state.registry.lock().await;
2842        reg.find_by_run_id(&run_id)
2843            .map(recover_artifacts_from_registry)
2844            .and_then(|a| a.pdf_path)
2845    };
2846    let ready = pdf_path.map(|p| p.exists()).unwrap_or(false);
2847    Json(serde_json::json!({"ready": ready})).into_response()
2848}
2849
2850/// Serve the HTML artifact for a run — view or download.
2851/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
2852/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
2853/// current-request Content-Security-Policy nonce check.
2854fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
2855    // Find the first nonce value that was baked in at render time.
2856    let Some(start) = html.find("nonce=\"") else {
2857        // Reports generated before nonce support was added have bare <style> and <script>
2858        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
2859        // the inline blocks — without it the browser blocks all CSS and JS.
2860        return html
2861            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
2862            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
2863    };
2864    let value_start = start + 7; // len(r#"nonce=""#) == 7
2865    let Some(end_offset) = html[value_start..].find('"') else {
2866        return html.to_owned();
2867    };
2868    let old_nonce = &html[value_start..value_start + end_offset];
2869    html.replace(
2870        &format!("nonce=\"{old_nonce}\""),
2871        &format!("nonce=\"{new_nonce}\""),
2872    )
2873}
2874
2875fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
2876    match fs::read_to_string(path) {
2877        Ok(raw) => {
2878            // Patch the saved nonce so inline styles/scripts pass CSP.
2879            let content = patch_html_nonce(&raw, csp_nonce);
2880            if wants_download {
2881                (
2882                    [
2883                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
2884                        (
2885                            header::CONTENT_DISPOSITION,
2886                            "attachment; filename=report.html",
2887                        ),
2888                    ],
2889                    content,
2890                )
2891                    .into_response()
2892            } else {
2893                Html(content).into_response()
2894            }
2895        }
2896        Err(err) => {
2897            let filename = path.file_name().map_or_else(
2898                || "report.html".to_string(),
2899                |n| n.to_string_lossy().into_owned(),
2900            );
2901            let msg = format!(
2902                "HTML report '{filename}' could not be read.\n\n\
2903                 Error: {err}\n\n\
2904                 If you moved or renamed the output folder, the stored path is now stale. \
2905                 Use 'Open HTML folder' from the results page to browse the output directory."
2906            );
2907            let html = ErrorTemplate {
2908                message: msg,
2909                last_report_url: Some("/view-reports".to_string()),
2910                last_report_label: Some("View Reports".to_string()),
2911                csp_nonce: csp_nonce.to_owned(),
2912            }
2913            .render()
2914            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
2915            (StatusCode::NOT_FOUND, Html(html)).into_response()
2916        }
2917    }
2918}
2919
2920/// Serve the PDF artifact for a run — inline or download.
2921fn serve_pdf_artifact(
2922    path: &Path,
2923    report_title: &str,
2924    run_id: &str,
2925    wants_download: bool,
2926    csp_nonce: &str,
2927) -> Response {
2928    match fs::read(path) {
2929        Ok(bytes) => {
2930            let filename = build_pdf_filename(report_title, run_id);
2931            let disposition = if wants_download {
2932                format!("attachment; filename=\"{filename}\"")
2933            } else {
2934                format!("inline; filename=\"{filename}\"")
2935            };
2936            (
2937                [
2938                    (header::CONTENT_TYPE, "application/pdf".to_string()),
2939                    (header::CONTENT_DISPOSITION, disposition),
2940                ],
2941                bytes,
2942            )
2943                .into_response()
2944        }
2945        Err(err) => {
2946            let filename = path.file_name().map_or_else(
2947                || "report.pdf".to_string(),
2948                |n| n.to_string_lossy().into_owned(),
2949            );
2950            let msg = format!(
2951                "PDF report '{filename}' could not be read.\n\n\
2952                 Error: {err}\n\n\
2953                 If you moved or renamed the output folder, the stored path is now stale. \
2954                 Use 'Open PDF folder' from the results page to browse the output directory."
2955            );
2956            let html = ErrorTemplate {
2957                message: msg,
2958                last_report_url: Some("/view-reports".to_string()),
2959                last_report_label: Some("View Reports".to_string()),
2960                csp_nonce: csp_nonce.to_owned(),
2961            }
2962            .render()
2963            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
2964            (StatusCode::NOT_FOUND, Html(html)).into_response()
2965        }
2966    }
2967}
2968
2969/// Serve the JSON artifact for a run — view or download.
2970fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
2971    match fs::read(path) {
2972        Ok(bytes) => {
2973            if wants_download {
2974                (
2975                    [
2976                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
2977                        (
2978                            header::CONTENT_DISPOSITION,
2979                            "attachment; filename=result.json",
2980                        ),
2981                    ],
2982                    bytes,
2983                )
2984                    .into_response()
2985            } else {
2986                (
2987                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
2988                    bytes,
2989                )
2990                    .into_response()
2991            }
2992        }
2993        Err(err) => {
2994            let filename = path.file_name().map_or_else(
2995                || "result.json".to_string(),
2996                |n| n.to_string_lossy().into_owned(),
2997            );
2998            let msg = format!(
2999                "JSON result '{filename}' could not be read.\n\n\
3000                 Error: {err}\n\n\
3001                 If you moved or renamed the output folder, the stored path is now stale. \
3002                 Use 'Open JSON folder' from the results page to browse the output directory."
3003            );
3004            let html = ErrorTemplate {
3005                message: msg,
3006                last_report_url: Some("/view-reports".to_string()),
3007                last_report_label: Some("View Reports".to_string()),
3008                csp_nonce: csp_nonce.to_owned(),
3009            }
3010            .render()
3011            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3012            (StatusCode::NOT_FOUND, Html(html)).into_response()
3013        }
3014    }
3015}
3016
3017/// Recover a `RunArtifacts` from the persisted registry for a run ID.
3018fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
3019    let output_dir = entry
3020        .html_path
3021        .as_ref()
3022        .or(entry.json_path.as_ref())
3023        .or(entry.pdf_path.as_ref())
3024        .and_then(|p| p.parent().map(PathBuf::from))
3025        .unwrap_or_default();
3026    // Recover pdf_path: use the persisted one, or look for report.pdf
3027    // adjacent to html/json if only the old entries lack it.
3028    let pdf_path = entry.pdf_path.clone().or_else(|| {
3029        let candidate = output_dir.join("report.pdf");
3030        candidate.exists().then_some(candidate)
3031    });
3032    RunArtifacts {
3033        output_dir: output_dir.clone(),
3034        html_path: entry.html_path.clone(),
3035        pdf_path,
3036        json_path: entry.json_path.clone(),
3037        scan_config_path: find_scan_config_in_dir(&output_dir),
3038        report_title: entry.project_label.clone(),
3039        result_context: RunResultContext::default(),
3040    }
3041}
3042
3043#[allow(clippy::too_many_lines)]
3044async fn artifact_handler(
3045    State(state): State<AppState>,
3046    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3047    AxumPath((run_id, artifact)): AxumPath<(String, String)>,
3048    Query(query): Query<ArtifactQuery>,
3049) -> Response {
3050    let artifact_set = {
3051        let registry = state.artifacts.lock().await;
3052        registry.get(&run_id).cloned()
3053    };
3054
3055    // Fall back to the persisted registry when the server was restarted and the
3056    // in-memory artifact map no longer holds the entry.
3057    let artifact_set = if let Some(a) = artifact_set {
3058        a
3059    } else {
3060        let reg = state.registry.lock().await;
3061        if let Some(entry) = reg.find_by_run_id(&run_id) {
3062            recover_artifacts_from_registry(entry)
3063        } else {
3064            let error_html = ErrorTemplate {
3065                message: format!(
3066                    "Report not found. Run ID {} is not in the scan history. \
3067                     The report may have been deleted, or this is an old run from \
3068                     before the scan registry was introduced.",
3069                    &run_id[..run_id.len().min(8)]
3070                ),
3071                last_report_url: Some("/view-reports".to_string()),
3072                last_report_label: Some("View Reports".to_string()),
3073                csp_nonce: csp_nonce.clone(),
3074            }
3075            .render()
3076            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3077            return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
3078        }
3079    };
3080
3081    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
3082
3083    match artifact.as_str() {
3084        "html" => {
3085            let Some(path) = artifact_set.html_path else {
3086                return StatusCode::NOT_FOUND.into_response();
3087            };
3088            serve_html_artifact(&path, wants_download, &csp_nonce)
3089        }
3090        "pdf" => {
3091            let Some(path) = artifact_set.pdf_path else {
3092                let msg = "PDF report was not generated for this run, or was not recorded in \
3093                           the scan registry. Re-run the analysis with PDF output enabled."
3094                    .to_string();
3095                let html = ErrorTemplate {
3096                    message: msg,
3097                    last_report_url: Some(format!("/runs/{run_id}/html")),
3098                    last_report_label: Some("View HTML Report".to_string()),
3099                    csp_nonce: csp_nonce.clone(),
3100                }
3101                .render()
3102                .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
3103                return (StatusCode::NOT_FOUND, Html(html)).into_response();
3104            };
3105            // PDF path is recorded but the background task may still be writing it.
3106            // Return a self-refreshing "please wait" page rather than an error.
3107            if !path.exists() {
3108                let html = format!(
3109                    "<!doctype html><html lang=\"en\"><head>\
3110                     <meta charset=utf-8>\
3111                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
3112                     <meta http-equiv=\"refresh\" content=\"5\">\
3113                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
3114                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
3115                     <style nonce=\"{csp_nonce}\">\
3116                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
3117                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
3118                     --nav:#b85d33;--nav-2:#7a371b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
3119                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
3120                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
3121                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
3122                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
3123                     background:var(--bg);color:var(--text);}}\
3124                     .top-nav{{position:sticky;top:0;z-index:30;\
3125                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
3126                     border-bottom:1px solid rgba(255,255,255,0.12);\
3127                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
3128                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
3129                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
3130                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;}}\
3131                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
3132                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
3133                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;min-width:0;}}\
3134                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
3135                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}}\
3136                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
3137                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
3138                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
3139                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
3140                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
3141                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
3142                     justify-content:center;min-height:38px;border-radius:999px;\
3143                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
3144                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
3145                     .theme-toggle .icon-sun{{display:none;}}\
3146                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
3147                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
3148                     .page{{max-width:1720px;margin:0 auto;padding:60px 24px;\
3149                     display:flex;align-items:center;justify-content:center;\
3150                     min-height:calc(100vh - 56px);}}\
3151                     .panel{{background:var(--surface);border:1px solid var(--line);\
3152                     border-radius:var(--radius);box-shadow:var(--shadow);\
3153                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
3154                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
3155                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
3156                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
3157                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
3158                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
3159                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
3160                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
3161                     min-height:42px;padding:0 20px;border-radius:14px;\
3162                     border:1px solid var(--line-strong);text-decoration:none;\
3163                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
3164                     .back-link:hover{{background:var(--line);}}\
3165                     </style></head>\
3166                     <body>\
3167                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
3168                       <a class=\"brand\" href=\"/\">\
3169                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
3170                         <div class=\"brand-copy\">\
3171                           <div class=\"brand-title\">OxideSLOC</div>\
3172                           <div class=\"brand-subtitle\">Local analysis workbench</div>\
3173                         </div>\
3174                       </a>\
3175                       <div class=\"nav-right\">\
3176                         <a class=\"nav-pill\" href=\"/\">Home</a>\
3177                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
3178                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
3179                           <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>\
3180                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
3181                           <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>\
3182                         </button>\
3183                       </div>\
3184                     </div></div>\
3185                     <div class=\"page\"><div class=\"panel\">\
3186                       <div class=\"spin-ring\"></div>\
3187                       <h1>Generating PDF\u{2026}</h1>\
3188                       <p>The PDF is being rendered from the HTML report.<br>\
3189                       This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
3190                       <a class=\"back-link\" href=\"/runs/{run_id}/pdf\">Refresh now</a>\
3191                     </div></div>\
3192                     <script nonce=\"{csp_nonce}\">\
3193                     (function(){{\
3194                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
3195                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
3196                       var t=document.getElementById(\"theme-toggle\");\
3197                       if(t)t.addEventListener(\"click\",function(){{\
3198                         var d=b.classList.toggle(\"dark-theme\");\
3199                         localStorage.setItem(k,d?\"dark\":\"light\");\
3200                       }});\
3201                     }})();\
3202                     </script>\
3203                     </body></html>"
3204                );
3205                return Html(html).into_response();
3206            }
3207            serve_pdf_artifact(
3208                &path,
3209                &artifact_set.report_title,
3210                &run_id,
3211                wants_download,
3212                &csp_nonce,
3213            )
3214        }
3215        "json" => {
3216            let Some(path) = artifact_set.json_path else {
3217                let msg = "JSON result was not generated for this run, or was not recorded in \
3218                           the scan registry. Re-run the analysis with JSON output enabled."
3219                    .to_string();
3220                let html = ErrorTemplate {
3221                    message: msg,
3222                    last_report_url: Some("/view-reports".to_string()),
3223                    last_report_label: Some("View Reports".to_string()),
3224                    csp_nonce: csp_nonce.clone(),
3225                }
3226                .render()
3227                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
3228                return (StatusCode::NOT_FOUND, Html(html)).into_response();
3229            };
3230            serve_json_artifact(&path, wants_download, &csp_nonce)
3231        }
3232        "scan-config" => {
3233            let path = artifact_set
3234                .scan_config_path
3235                .as_deref()
3236                .map(|p| p.to_path_buf())
3237                .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
3238                .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
3239            fs::read(&path).map_or_else(
3240                |_| StatusCode::NOT_FOUND.into_response(),
3241                |bytes| {
3242                    (
3243                        [
3244                            (
3245                                header::CONTENT_TYPE,
3246                                "application/json; charset=utf-8".to_string(),
3247                            ),
3248                            (
3249                                header::CONTENT_DISPOSITION,
3250                                "attachment; filename=\"scan-config.json\"".to_string(),
3251                            ),
3252                        ],
3253                        bytes,
3254                    )
3255                        .into_response()
3256                },
3257            )
3258        }
3259        _ if artifact.starts_with("sub_") => {
3260            if artifact.len() > 128
3261                || !artifact
3262                    .chars()
3263                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
3264            {
3265                return StatusCode::BAD_REQUEST.into_response();
3266            }
3267            let filename = format!("{artifact}.html");
3268            let path = artifact_set.output_dir.join(&filename);
3269            if !path.exists() {
3270                let html = ErrorTemplate {
3271                    message: format!(
3272                        "Sub-report '{artifact}' was not found in the run directory.\n\
3273                         Re-run the analysis with 'Detect and separate git submodules' \
3274                         and HTML output enabled."
3275                    ),
3276                    last_report_url: Some("/view-reports".to_string()),
3277                    last_report_label: Some("View Reports".to_string()),
3278                    csp_nonce: csp_nonce.clone(),
3279                }
3280                .render()
3281                .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
3282                return (StatusCode::NOT_FOUND, Html(html)).into_response();
3283            }
3284            serve_html_artifact(&path, wants_download, &csp_nonce)
3285        }
3286        _ => StatusCode::NOT_FOUND.into_response(),
3287    }
3288}
3289
3290// ── History ───────────────────────────────────────────────────────────────────
3291
3292struct SubmoduleLinkRow {
3293    name: String,
3294    url: String,
3295}
3296
3297struct HistoryEntryRow {
3298    run_id: String,
3299    run_id_short: String,
3300    timestamp: String,
3301    project_label: String,
3302    project_path: String,
3303    files_analyzed: u64,
3304    files_skipped: u64,
3305    code_lines: u64,
3306    comment_lines: u64,
3307    blank_lines: u64,
3308    git_branch: String,
3309    git_commit: String,
3310    has_html: bool,
3311    has_json: bool,
3312    has_pdf: bool,
3313    submodule_links: Vec<SubmoduleLinkRow>,
3314    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
3315    submodule_names_csv: String,
3316}
3317
3318fn fmt_pst(dt: chrono::DateTime<chrono::Utc>) -> String {
3319    dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset is always valid"))
3320        .format("%Y-%m-%d %H:%M PST")
3321        .to_string()
3322}
3323
3324fn fmt_git_date(iso: &str) -> Option<String> {
3325    chrono::DateTime::parse_from_rfc3339(iso)
3326        .ok()
3327        .map(|d| fmt_pst(d.with_timezone(&chrono::Utc)))
3328}
3329
3330fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
3331    reg.entries
3332        .iter()
3333        .map(|e| {
3334            let submodule_links = {
3335                let mut links: Vec<SubmoduleLinkRow> = vec![];
3336                let sub_dir = e
3337                    .html_path
3338                    .as_ref()
3339                    .and_then(|p| p.parent())
3340                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
3341                if let Some(dir) = sub_dir {
3342                    if let Ok(rd) = std::fs::read_dir(dir) {
3343                        for entry_res in rd.flatten() {
3344                            let fname = entry_res.file_name();
3345                            let fname_str = fname.to_string_lossy();
3346                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
3347                                let stem = &fname_str[..fname_str.len() - 5];
3348                                let display = stem[4..].replace('-', " ");
3349                                links.push(SubmoduleLinkRow {
3350                                    name: display,
3351                                    url: format!("/runs/{}/{stem}", e.run_id),
3352                                });
3353                            }
3354                        }
3355                    }
3356                }
3357                links.sort_by(|a, b| a.name.cmp(&b.name));
3358                links
3359            };
3360            let submodule_names_csv = submodule_links
3361                .iter()
3362                .map(|l| l.name.as_str())
3363                .collect::<Vec<_>>()
3364                .join(",");
3365            HistoryEntryRow {
3366                run_id: e.run_id.clone(),
3367                run_id_short: e
3368                    .run_id
3369                    .split('-')
3370                    .next_back()
3371                    .unwrap_or(&e.run_id)
3372                    .chars()
3373                    .take(7)
3374                    .collect(),
3375                timestamp: fmt_pst(e.timestamp_utc),
3376                project_label: e.project_label.clone(),
3377                project_path: e
3378                    .input_roots
3379                    .first()
3380                    .map(|s| sanitize_path_str(s))
3381                    .unwrap_or_default(),
3382                files_analyzed: e.summary.files_analyzed,
3383                files_skipped: e.summary.files_skipped,
3384                code_lines: e.summary.code_lines,
3385                comment_lines: e.summary.comment_lines,
3386                blank_lines: e.summary.blank_lines,
3387                git_branch: e.git_branch.clone().unwrap_or_default(),
3388                git_commit: e.git_commit.clone().unwrap_or_default(),
3389                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
3390                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
3391                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
3392                submodule_links,
3393                submodule_names_csv,
3394            }
3395        })
3396        .collect()
3397}
3398
3399#[derive(Deserialize, Default)]
3400struct HistoryQuery {
3401    linked: Option<String>,
3402}
3403
3404async fn history_handler(
3405    State(state): State<AppState>,
3406    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3407    Query(query): Query<HistoryQuery>,
3408) -> impl IntoResponse {
3409    let mut entries = {
3410        let reg = state.registry.lock().await;
3411        make_history_rows(&reg)
3412    };
3413    entries.retain(|e| e.has_html);
3414    let total_scans = entries.len();
3415    let linked_count = query
3416        .linked
3417        .as_deref()
3418        .and_then(|s| s.parse::<usize>().ok())
3419        .unwrap_or(0);
3420    let template = HistoryTemplate {
3421        version: env!("CARGO_PKG_VERSION"),
3422        entries,
3423        total_scans,
3424        linked_count,
3425        csp_nonce,
3426    };
3427    Html(
3428        template
3429            .render()
3430            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
3431    )
3432    .into_response()
3433}
3434
3435async fn compare_select_handler(
3436    State(state): State<AppState>,
3437    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3438) -> impl IntoResponse {
3439    let mut entries = {
3440        let reg = state.registry.lock().await;
3441        make_history_rows(&reg)
3442    };
3443    entries.retain(|e| e.has_json);
3444    let total_scans = entries.len();
3445    let template = CompareSelectTemplate {
3446        version: env!("CARGO_PKG_VERSION"),
3447        entries,
3448        total_scans,
3449        csp_nonce,
3450    };
3451    Html(
3452        template
3453            .render()
3454            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
3455    )
3456    .into_response()
3457}
3458
3459// ── Compare ───────────────────────────────────────────────────────────────────
3460
3461#[derive(Deserialize, Default)]
3462struct CompareQuery {
3463    a: Option<String>,
3464    b: Option<String>,
3465    /// Optional submodule name to scope the comparison to one submodule.
3466    sub: Option<String>,
3467    /// "super" to exclude all submodule files and show only the super-repo.
3468    scope: Option<String>,
3469}
3470
3471struct CompareFileDeltaRow {
3472    relative_path: String,
3473    language: String,
3474    status: String,
3475    baseline_code: i64,
3476    current_code: i64,
3477    code_delta_str: String,
3478    code_delta_class: String,
3479    comment_delta_str: String,
3480    comment_delta_class: String,
3481    total_delta_str: String,
3482    total_delta_class: String,
3483}
3484
3485/// Recompute `summary_totals` from the current `per_file_records` slice.
3486/// Used when per_file_records has been narrowed to a submodule subset.
3487fn recompute_summary_from_records(run: &mut AnalysisRun) {
3488    let files_analyzed = run
3489        .per_file_records
3490        .iter()
3491        .filter(|r| r.language.is_some())
3492        .count() as u64;
3493    let code_lines: u64 = run
3494        .per_file_records
3495        .iter()
3496        .map(|r| r.effective_counts.code_lines)
3497        .sum();
3498    let comment_lines: u64 = run
3499        .per_file_records
3500        .iter()
3501        .map(|r| r.effective_counts.comment_lines)
3502        .sum();
3503    let blank_lines: u64 = run
3504        .per_file_records
3505        .iter()
3506        .map(|r| r.effective_counts.blank_lines)
3507        .sum();
3508    run.summary_totals.files_analyzed = files_analyzed;
3509    run.summary_totals.files_considered = files_analyzed;
3510    run.summary_totals.code_lines = code_lines;
3511    run.summary_totals.comment_lines = comment_lines;
3512    run.summary_totals.blank_lines = blank_lines;
3513    run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
3514}
3515
3516fn fmt_delta(n: i64) -> String {
3517    if n > 0 {
3518        format!("+{n}")
3519    } else {
3520        format!("{n}")
3521    }
3522}
3523
3524fn delta_class(n: i64) -> &'static str {
3525    use std::cmp::Ordering;
3526    match n.cmp(&0) {
3527        Ordering::Greater => "pos",
3528        Ordering::Less => "neg",
3529        Ordering::Equal => "zero",
3530    }
3531}
3532
3533fn fmt_pct(delta: i64, baseline: u64) -> String {
3534    if baseline == 0 {
3535        return "—".to_string();
3536    }
3537    let pct = (delta as f64 / baseline as f64) * 100.0;
3538    if pct > 0.049 {
3539        format!("+{pct:.1}%")
3540    } else if pct < -0.049 {
3541        format!("{pct:.1}%")
3542    } else {
3543        "±0%".to_string()
3544    }
3545}
3546
3547/// Returns (`display_string`, `css_class`) for a numeric change column cell.
3548fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
3549    prev.map_or_else(
3550        || ("—".to_string(), "na"),
3551        |p| {
3552            #[allow(clippy::cast_possible_wrap)]
3553            let d = curr as i64 - p as i64;
3554            (fmt_delta(d), delta_class(d))
3555        },
3556    )
3557}
3558
3559#[allow(clippy::too_many_lines)]
3560async fn compare_handler(
3561    State(state): State<AppState>,
3562    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3563    Query(query): Query<CompareQuery>,
3564) -> impl IntoResponse {
3565    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
3566    // redirect to the history page where the user can select two runs.
3567    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
3568        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
3569        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
3570    };
3571
3572    let (maybe_a, maybe_b) = {
3573        let reg = state.registry.lock().await;
3574        (
3575            reg.find_by_run_id(&run_id_a).cloned(),
3576            reg.find_by_run_id(&run_id_b).cloned(),
3577        )
3578    };
3579
3580    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
3581        let html = ErrorTemplate {
3582            message: "One or both run IDs were not found in scan history. \
3583                      The runs may have been deleted or the registry may have been reset."
3584                .to_string(),
3585            last_report_url: Some("/compare-scans".to_string()),
3586            last_report_label: Some("Compare Scans".to_string()),
3587            csp_nonce: csp_nonce.clone(),
3588        }
3589        .render()
3590        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
3591        return Html(html).into_response();
3592    };
3593
3594    // Ensure older scan is always the baseline.
3595    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
3596        (entry_a, entry_b)
3597    } else {
3598        (entry_b, entry_a)
3599    };
3600
3601    // If query params were in the wrong order, redirect to canonical URL so the
3602    // browser always shows the same URL for the same two scans regardless of how
3603    // the user arrived here (Full diff button vs. Compare Scans selection).
3604    if baseline_entry.run_id != run_id_a {
3605        let canonical = format!(
3606            "/compare?a={}&b={}",
3607            baseline_entry.run_id, current_entry.run_id
3608        );
3609        return axum::response::Redirect::to(&canonical).into_response();
3610    }
3611
3612    let (Some(base_json), Some(curr_json)) = (
3613        baseline_entry.json_path.as_ref(),
3614        current_entry.json_path.as_ref(),
3615    ) else {
3616        let html = ErrorTemplate {
3617            message: "Full comparison requires JSON scan data, which was not saved for one or \
3618                      both of these runs. JSON is now always saved for new scans — re-run the \
3619                      affected projects to enable comparisons."
3620                .to_string(),
3621            last_report_url: Some("/compare-scans".to_string()),
3622            last_report_label: Some("Compare Scans".to_string()),
3623            csp_nonce: csp_nonce.clone(),
3624        }
3625        .render()
3626        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
3627        return Html(html).into_response();
3628    };
3629
3630    let baseline_run = match read_json(base_json) {
3631        Ok(r) => r,
3632        Err(e) => {
3633            let message = if state.server_mode {
3634                "Could not load baseline scan data. The scan output folder may have been moved, \
3635                 renamed, or deleted. Re-running the analysis will create fresh comparison data."
3636                    .to_string()
3637            } else {
3638                format!(
3639                    "Could not load baseline scan data.\n\nPath: {}\n\nError: {e}\n\n\
3640                     The scan output folder may have been moved, renamed, or deleted. \
3641                     Re-running the analysis for this project will create fresh comparison data.",
3642                    base_json.display()
3643                )
3644            };
3645            let html = ErrorTemplate {
3646                message,
3647                last_report_url: Some("/compare-scans".to_string()),
3648                last_report_label: Some("Compare Scans".to_string()),
3649                csp_nonce: csp_nonce.clone(),
3650            }
3651            .render()
3652            .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
3653            return (StatusCode::NOT_FOUND, Html(html)).into_response();
3654        }
3655    };
3656    let current_run = match read_json(curr_json) {
3657        Ok(r) => r,
3658        Err(e) => {
3659            let message = if state.server_mode {
3660                "Could not load current scan data. The scan output folder may have been moved, \
3661                 renamed, or deleted. Re-running the analysis will create fresh comparison data."
3662                    .to_string()
3663            } else {
3664                format!(
3665                    "Could not load current scan data.\n\nPath: {}\n\nError: {e}\n\n\
3666                     The scan output folder may have been moved, renamed, or deleted. \
3667                     Re-running the analysis for this project will create fresh comparison data.",
3668                    curr_json.display()
3669                )
3670            };
3671            let html = ErrorTemplate {
3672                message,
3673                last_report_url: Some("/compare-scans".to_string()),
3674                last_report_label: Some("Compare Scans".to_string()),
3675                csp_nonce: csp_nonce.clone(),
3676            }
3677            .render()
3678            .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
3679            return (StatusCode::NOT_FOUND, Html(html)).into_response();
3680        }
3681    };
3682
3683    let active_submodule = query.sub.clone();
3684    let super_scope_active = query.scope.as_deref() == Some("super");
3685
3686    // Build the union of submodule names present in either run so users can
3687    // scope to a submodule even when it only exists in one of the two scans.
3688    let submodule_options = {
3689        let mut names = std::collections::BTreeSet::new();
3690        for s in &baseline_run.submodule_summaries {
3691            names.insert(s.name.clone());
3692        }
3693        for s in &current_run.submodule_summaries {
3694            names.insert(s.name.clone());
3695        }
3696        names.into_iter().collect::<Vec<_>>()
3697    };
3698    let has_any_submodule_data = !submodule_options.is_empty();
3699
3700    // Narrow per_file_records when a scope is active, then recompute totals.
3701    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
3702        let mut b = baseline_run.clone();
3703        let mut c = current_run.clone();
3704        b.per_file_records
3705            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
3706        c.per_file_records
3707            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
3708        recompute_summary_from_records(&mut b);
3709        recompute_summary_from_records(&mut c);
3710        (b, c)
3711    } else if super_scope_active {
3712        let mut b = baseline_run.clone();
3713        let mut c = current_run.clone();
3714        b.per_file_records.retain(|f| f.submodule.is_none());
3715        c.per_file_records.retain(|f| f.submodule.is_none());
3716        recompute_summary_from_records(&mut b);
3717        recompute_summary_from_records(&mut c);
3718        (b, c)
3719    } else {
3720        (baseline_run, current_run)
3721    };
3722
3723    let comparison = compute_delta(&effective_baseline, &effective_current);
3724
3725    let file_rows: Vec<CompareFileDeltaRow> = comparison
3726        .file_deltas
3727        .iter()
3728        .map(|d| CompareFileDeltaRow {
3729            relative_path: d.relative_path.clone(),
3730            language: d.language.clone().unwrap_or_else(|| "—".into()),
3731            status: match d.status {
3732                FileChangeStatus::Added => "added".into(),
3733                FileChangeStatus::Removed => "removed".into(),
3734                FileChangeStatus::Modified => "modified".into(),
3735                FileChangeStatus::Unchanged => "unchanged".into(),
3736            },
3737            baseline_code: d.baseline_code,
3738            current_code: d.current_code,
3739            code_delta_str: fmt_delta(d.code_delta),
3740            code_delta_class: delta_class(d.code_delta).into(),
3741            comment_delta_str: fmt_delta(d.comment_delta),
3742            comment_delta_class: delta_class(d.comment_delta).into(),
3743            total_delta_str: fmt_delta(d.total_delta),
3744            total_delta_class: delta_class(d.total_delta).into(),
3745        })
3746        .collect();
3747
3748    let project_path = baseline_entry
3749        .input_roots
3750        .first()
3751        .map(|s| sanitize_path_str(s))
3752        .unwrap_or_default();
3753    let lines_added = sum_added_code_lines(&comparison);
3754    let lines_removed = sum_removed_code_lines(&comparison);
3755    // True when the selected scope had no files in the baseline — e.g. comparing a submodule
3756    // that only exists in the current scan or using Super-repo only on an older scan.
3757    let new_scope = comparison.summary.baseline_code == 0 && comparison.summary.current_code > 0;
3758    let churn_pct = if comparison.summary.baseline_code > 0 {
3759        (lines_added + lines_removed) as f64 / comparison.summary.baseline_code as f64 * 100.0
3760    } else {
3761        0.0
3762    };
3763    let scope_flag = new_scope
3764        || (comparison.summary.baseline_code > 0
3765            && lines_added as f64 / comparison.summary.baseline_code as f64 > 0.20);
3766    let s = &comparison.summary;
3767    let template = CompareTemplate {
3768        version: env!("CARGO_PKG_VERSION"),
3769        project_label: baseline_entry.project_label.clone(),
3770        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
3771        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
3772        baseline_run_id: baseline_entry.run_id.clone(),
3773        current_run_id: current_entry.run_id.clone(),
3774        baseline_run_id_short: baseline_entry
3775            .run_id
3776            .split('-')
3777            .next_back()
3778            .unwrap_or(&baseline_entry.run_id)
3779            .chars()
3780            .take(7)
3781            .collect(),
3782        current_run_id_short: current_entry
3783            .run_id
3784            .split('-')
3785            .next_back()
3786            .unwrap_or(&current_entry.run_id)
3787            .chars()
3788            .take(7)
3789            .collect(),
3790        baseline_timestamp: fmt_pst(baseline_entry.timestamp_utc),
3791        current_timestamp: fmt_pst(current_entry.timestamp_utc),
3792        project_path: project_path.clone(),
3793        baseline_code: s.baseline_code,
3794        current_code: s.current_code,
3795        code_lines_delta_str: fmt_delta(s.code_lines_delta),
3796        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
3797        baseline_files: s.baseline_files,
3798        current_files: s.current_files,
3799        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
3800        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
3801        baseline_comments: s.baseline_comments,
3802        current_comments: s.current_comments,
3803        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
3804        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
3805        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
3806        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
3807        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
3808        code_lines_added: lines_added,
3809        code_lines_removed: lines_removed,
3810        new_scope,
3811        churn_rate_str: if new_scope {
3812            "New".to_string()
3813        } else if s.baseline_code > 0 {
3814            format!("{churn_pct:.1}%")
3815        } else {
3816            "—".to_string()
3817        },
3818        churn_rate_class: if new_scope || churn_pct > 20.0 {
3819            "high".into()
3820        } else if churn_pct > 5.0 {
3821            "med".into()
3822        } else {
3823            "low".into()
3824        },
3825        scope_flag,
3826        files_added: comparison.files_added,
3827        files_removed: comparison.files_removed,
3828        files_modified: comparison.files_modified,
3829        files_unchanged: comparison.files_unchanged,
3830        file_rows,
3831        baseline_git_author: baseline_entry.git_author.clone(),
3832        current_git_author: current_entry.git_author.clone(),
3833        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
3834        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
3835        baseline_git_tags: baseline_entry.git_tags.clone(),
3836        current_git_tags: current_entry.git_tags.clone(),
3837        baseline_git_commit_date: baseline_entry
3838            .git_commit_date
3839            .as_deref()
3840            .and_then(fmt_git_date),
3841        current_git_commit_date: current_entry
3842            .git_commit_date
3843            .as_deref()
3844            .and_then(fmt_git_date),
3845        project_name: project_path
3846            .rsplit(['/', '\\'])
3847            .find(|s| !s.is_empty())
3848            .unwrap_or(&project_path)
3849            .to_string(),
3850        submodule_options,
3851        has_any_submodule_data,
3852        active_submodule,
3853        super_scope_active,
3854        csp_nonce,
3855    };
3856
3857    Html(
3858        template
3859            .render()
3860            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
3861    )
3862    .into_response()
3863}
3864
3865// ── Badge endpoint ────────────────────────────────────────────────────────────
3866// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
3867// pages, Jira descriptions, etc.
3868//
3869// GET /badge/<metric>?label=<override>&color=<hex>
3870// Metrics: code-lines  files  comment-lines  blank-lines
3871
3872fn format_number(n: u64) -> String {
3873    let s = n.to_string();
3874    let mut out = String::with_capacity(s.len() + s.len() / 3);
3875    let len = s.len();
3876    for (i, c) in s.chars().enumerate() {
3877        if i > 0 && (len - i).is_multiple_of(3) {
3878            out.push(',');
3879        }
3880        out.push(c);
3881    }
3882    out
3883}
3884
3885const fn badge_char_width(c: char) -> f64 {
3886    match c {
3887        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
3888        'm' | 'w' => 9.0,
3889        ' ' => 4.0,
3890        _ => 6.5,
3891    }
3892}
3893
3894#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3895fn badge_text_px(text: &str) -> u32 {
3896    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
3897}
3898
3899fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
3900    let lw = badge_text_px(label) + 20;
3901    let rw = badge_text_px(value) + 20;
3902    let total = lw + rw;
3903    let lx = lw / 2;
3904    let rx = lw + rw / 2;
3905    let le = escape_html(label);
3906    let ve = escape_html(value);
3907    let ce = escape_html(color);
3908    format!(
3909        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
3910  <rect width="{total}" height="20" fill="#555"/>
3911  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
3912  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
3913    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
3914    <text x="{lx}" y="13">{le}</text>
3915    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
3916    <text x="{rx}" y="13">{ve}</text>
3917  </g>
3918</svg>"##
3919    )
3920}
3921
3922#[derive(Deserialize)]
3923struct BadgeQuery {
3924    label: Option<String>,
3925    color: Option<String>,
3926}
3927
3928async fn badge_handler(
3929    State(state): State<AppState>,
3930    AxumPath(metric): AxumPath<String>,
3931    Query(query): Query<BadgeQuery>,
3932) -> Response {
3933    let entry = {
3934        let reg = state.registry.lock().await;
3935        reg.entries.first().cloned()
3936    };
3937
3938    let Some(entry) = entry else {
3939        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
3940        return (
3941            [
3942                (header::CONTENT_TYPE, "image/svg+xml"),
3943                (header::CACHE_CONTROL, "no-cache, max-age=0"),
3944            ],
3945            svg,
3946        )
3947            .into_response();
3948    };
3949
3950    let (default_label, value, default_color) = match metric.as_str() {
3951        "code-lines" => (
3952            "code lines",
3953            format_number(entry.summary.code_lines),
3954            "#4a78ee",
3955        ),
3956        "files" => (
3957            "files analyzed",
3958            format_number(entry.summary.files_analyzed),
3959            "#4a9862",
3960        ),
3961        "comment-lines" => (
3962            "comment lines",
3963            format_number(entry.summary.comment_lines),
3964            "#b35428",
3965        ),
3966        "blank-lines" => (
3967            "blank lines",
3968            format_number(entry.summary.blank_lines),
3969            "#7a5db0",
3970        ),
3971        _ => return StatusCode::NOT_FOUND.into_response(),
3972    };
3973
3974    let label = query.label.as_deref().unwrap_or(default_label);
3975    let color = query.color.as_deref().unwrap_or(default_color);
3976    let svg = render_badge_svg(label, &value, color);
3977
3978    (
3979        [
3980            (header::CONTENT_TYPE, "image/svg+xml"),
3981            (header::CACHE_CONTROL, "no-cache, max-age=0"),
3982        ],
3983        svg,
3984    )
3985        .into_response()
3986}
3987
3988// ── Metrics API ───────────────────────────────────────────────────────────────
3989// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
3990// Confluence automation, Jira webhooks, etc.
3991//
3992// GET /api/metrics/latest
3993// GET /api/metrics/<run_id>
3994
3995#[derive(Serialize)]
3996struct ApiMetricsResponse {
3997    run_id: String,
3998    timestamp: String,
3999    project: String,
4000    summary: ApiSummaryPayload,
4001    languages: Vec<ApiLanguageRow>,
4002}
4003
4004#[derive(Serialize)]
4005struct ApiSummaryPayload {
4006    files_analyzed: u64,
4007    files_skipped: u64,
4008    code_lines: u64,
4009    comment_lines: u64,
4010    blank_lines: u64,
4011    total_physical_lines: u64,
4012    functions: u64,
4013    classes: u64,
4014    variables: u64,
4015    imports: u64,
4016}
4017
4018#[derive(Serialize)]
4019struct ApiLanguageRow {
4020    name: String,
4021    files: u64,
4022    code_lines: u64,
4023    comment_lines: u64,
4024    blank_lines: u64,
4025    functions: u64,
4026    classes: u64,
4027    variables: u64,
4028    imports: u64,
4029}
4030
4031async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
4032    let entry = {
4033        let reg = state.registry.lock().await;
4034        reg.entries.first().cloned()
4035    };
4036    entry.map_or_else(
4037        || {
4038            (
4039                StatusCode::NOT_FOUND,
4040                Json(serde_json::json!({"error": "no scans recorded yet"})),
4041            )
4042                .into_response()
4043        },
4044        |e| build_metrics_response(&e),
4045    )
4046}
4047
4048async fn api_metrics_run_handler(
4049    State(state): State<AppState>,
4050    AxumPath(run_id): AxumPath<String>,
4051) -> Response {
4052    let entry = {
4053        let reg = state.registry.lock().await;
4054        reg.find_by_run_id(&run_id).cloned()
4055    };
4056    entry.map_or_else(
4057        || {
4058            (
4059                StatusCode::NOT_FOUND,
4060                Json(serde_json::json!({"error": "run not found"})),
4061            )
4062                .into_response()
4063        },
4064        |e| build_metrics_response(&e),
4065    )
4066}
4067
4068fn build_metrics_response(entry: &RegistryEntry) -> Response {
4069    let languages: Vec<ApiLanguageRow> = entry
4070        .json_path
4071        .as_ref()
4072        .and_then(|p| read_json(p).ok())
4073        .map(|run| {
4074            run.totals_by_language
4075                .iter()
4076                .map(|l| ApiLanguageRow {
4077                    name: l.language.display_name().to_string(),
4078                    files: l.files,
4079                    code_lines: l.code_lines,
4080                    comment_lines: l.comment_lines,
4081                    blank_lines: l.blank_lines,
4082                    functions: l.functions,
4083                    classes: l.classes,
4084                    variables: l.variables,
4085                    imports: l.imports,
4086                })
4087                .collect()
4088        })
4089        .unwrap_or_default();
4090
4091    let s = &entry.summary;
4092    Json(ApiMetricsResponse {
4093        run_id: entry.run_id.clone(),
4094        timestamp: entry.timestamp_utc.to_rfc3339(),
4095        project: entry.project_label.clone(),
4096        summary: ApiSummaryPayload {
4097            files_analyzed: s.files_analyzed,
4098            files_skipped: s.files_skipped,
4099            code_lines: s.code_lines,
4100            comment_lines: s.comment_lines,
4101            blank_lines: s.blank_lines,
4102            total_physical_lines: s.total_physical_lines,
4103            functions: s.functions,
4104            classes: s.classes,
4105            variables: s.variables,
4106            imports: s.imports,
4107        },
4108        languages,
4109    })
4110    .into_response()
4111}
4112
4113// ── Project history API ───────────────────────────────────────────────────────
4114// Protected. Called by the wizard JS when the project path changes, so the UI
4115// can show a "scanned N times before" badge without a full page reload.
4116//
4117// GET /api/project-history?path=<project_root>
4118
4119#[derive(Deserialize)]
4120struct ProjectHistoryQuery {
4121    path: Option<String>,
4122}
4123
4124#[derive(Serialize)]
4125struct ProjectHistoryResponse {
4126    scan_count: usize,
4127    last_scan_id: Option<String>,
4128    last_scan_timestamp: Option<String>,
4129    last_scan_code_lines: Option<u64>,
4130    last_git_branch: Option<String>,
4131    last_git_commit: Option<String>,
4132}
4133
4134async fn project_history_handler(
4135    State(state): State<AppState>,
4136    Query(query): Query<ProjectHistoryQuery>,
4137) -> Response {
4138    let path = query.path.unwrap_or_default();
4139    let resolved = resolve_input_path(&path);
4140    let root_str = resolved.to_string_lossy().replace('\\', "/");
4141
4142    let entries: Vec<_> = {
4143        let reg = state.registry.lock().await;
4144        reg.entries
4145            .iter()
4146            .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
4147            .cloned()
4148            .collect()
4149    };
4150    let scan_count = entries.len();
4151    let last = entries.first();
4152    let last_scan_id = last.map(|e| e.run_id.clone());
4153    let last_scan_timestamp = last.map(|e| fmt_pst(e.timestamp_utc));
4154    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
4155    let last_git_branch = last.and_then(|e| e.git_branch.clone());
4156    let last_git_commit = last.and_then(|e| e.git_commit.clone());
4157
4158    Json(ProjectHistoryResponse {
4159        scan_count,
4160        last_scan_id,
4161        last_scan_timestamp,
4162        last_scan_code_lines,
4163        last_git_branch,
4164        last_git_commit,
4165    })
4166    .into_response()
4167}
4168
4169// ── Embeddable widget ─────────────────────────────────────────────────────────
4170// Protected. Returns a self-contained HTML page suitable for iframing inside
4171// Jenkins build summaries, Confluence iframe macros, or Jira panels.
4172//
4173// GET /embed/summary?run_id=<uuid>&theme=dark
4174
4175#[derive(Deserialize)]
4176struct EmbedQuery {
4177    run_id: Option<String>,
4178    theme: Option<String>,
4179}
4180
4181async fn embed_handler(
4182    State(state): State<AppState>,
4183    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4184    Query(query): Query<EmbedQuery>,
4185) -> Response {
4186    let entry = {
4187        let reg = state.registry.lock().await;
4188        query.run_id.as_ref().map_or_else(
4189            || reg.entries.first().cloned(),
4190            |id| reg.find_by_run_id(id).cloned(),
4191        )
4192    };
4193
4194    let Some(entry) = entry else {
4195        return Html(
4196            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
4197                .to_string(),
4198        )
4199        .into_response();
4200    };
4201
4202    let dark = query.theme.as_deref() == Some("dark");
4203    let languages: Vec<(String, u64, u64)> = entry
4204        .json_path
4205        .as_ref()
4206        .and_then(|p| read_json(p).ok())
4207        .map(|run| {
4208            run.totals_by_language
4209                .iter()
4210                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
4211                .collect()
4212        })
4213        .unwrap_or_default();
4214
4215    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
4216}
4217
4218fn render_embed_widget(
4219    entry: &RegistryEntry,
4220    languages: &[(String, u64, u64)],
4221    dark: bool,
4222    csp_nonce: &str,
4223) -> String {
4224    let s = &entry.summary;
4225    let total = s.code_lines + s.comment_lines + s.blank_lines;
4226    let code_pct = s
4227        .code_lines
4228        .checked_mul(100)
4229        .and_then(|n| n.checked_div(total))
4230        .unwrap_or(0);
4231
4232    let (bg, fg, surface, muted, border) = if dark {
4233        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
4234    } else {
4235        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
4236    };
4237
4238    let mut lang_rows = String::new();
4239    for (name, files, code) in languages {
4240        write!(
4241            lang_rows,
4242            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
4243            escape_html(name),
4244            format_number(*files),
4245            format_number(*code),
4246        )
4247        .ok();
4248    }
4249
4250    let lang_table = if lang_rows.is_empty() {
4251        String::new()
4252    } else {
4253        format!(
4254            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
4255        )
4256    };
4257
4258    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
4259    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
4260    let project_esc = escape_html(&entry.project_label);
4261    let code_lines = format_number(s.code_lines);
4262    let comment_lines = format_number(s.comment_lines);
4263    let files = format_number(s.files_analyzed);
4264    let code_raw = s.code_lines;
4265    let comment_raw = s.comment_lines;
4266    let blank_raw = s.blank_lines;
4267
4268    format!(
4269        r#"<!doctype html>
4270<html lang="en">
4271<head>
4272  <meta charset="utf-8">
4273  <meta name="viewport" content="width=device-width,initial-scale=1">
4274  <title>OxideSLOC &mdash; {project_esc}</title>
4275  <script src="/static/chart.js"></script>
4276  <style nonce="{csp_nonce}">
4277    *{{box-sizing:border-box;margin:0;padding:0}}
4278    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
4279    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
4280    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
4281    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
4282    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
4283    .card .v{{font-size:18px;font-weight:700}}
4284    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
4285    .row{{display:flex;gap:12px;align-items:flex-start}}
4286    .pie{{width:120px;height:120px;flex-shrink:0}}
4287    .lt{{border-collapse:collapse;width:100%;flex:1}}
4288    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
4289    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
4290    .n{{text-align:right}}
4291    .footer{{margin-top:10px;color:{muted};font-size:10px}}
4292  </style>
4293</head>
4294<body>
4295  <h2>{project_esc}</h2>
4296  <div class="sub">{timestamp} &middot; run {run_short}</div>
4297  <div class="cards">
4298    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
4299    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
4300    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
4301    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
4302  </div>
4303  <div class="row">
4304    <canvas class="pie" id="c"></canvas>
4305    {lang_table}
4306  </div>
4307  <div class="footer">oxide-sloc</div>
4308  <script nonce="{csp_nonce}">
4309    new Chart(document.getElementById('c'),{{
4310      type:'doughnut',
4311      data:{{
4312        labels:['Code','Comments','Blank'],
4313        datasets:[{{
4314          data:[{code_raw},{comment_raw},{blank_raw}],
4315          backgroundColor:['#4a78ee','#b35428','#aaa'],
4316          borderWidth:0
4317        }}]
4318      }},
4319      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
4320    }});
4321  </script>
4322</body>
4323</html>"#
4324    )
4325}
4326
4327#[allow(clippy::too_many_arguments)]
4328fn persist_run_artifacts(
4329    run: &sloc_core::AnalysisRun,
4330    report_html: &str,
4331    run_dir: &Path,
4332    generate_json: bool,
4333    generate_html: bool,
4334    generate_pdf: bool,
4335    report_title: &str,
4336    file_stem: &str,
4337    result_context: RunResultContext,
4338) -> Result<(RunArtifacts, PendingPdf)> {
4339    fs::create_dir_all(run_dir)
4340        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
4341
4342    let mut html_path = None;
4343    let mut pdf_path = None;
4344    let mut json_path = None;
4345    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
4346
4347    if generate_html {
4348        let path = run_dir.join(format!("report_{file_stem}.html"));
4349        fs::write(&path, report_html)
4350            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
4351        html_path = Some(path);
4352    }
4353
4354    if generate_json {
4355        let path = run_dir.join(format!("result_{file_stem}.json"));
4356        let json = serde_json::to_string_pretty(run)
4357            .context("failed to serialize analysis run to JSON")?;
4358        fs::write(&path, json)
4359            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
4360        json_path = Some(path);
4361    }
4362
4363    if generate_pdf {
4364        let source_html_path = if let Some(existing) = html_path.as_ref() {
4365            existing.clone()
4366        } else {
4367            let temp_html = run_dir.join("_report_rendered.html");
4368            fs::write(&temp_html, report_html).with_context(|| {
4369                format!(
4370                    "failed to write temporary HTML report to {}",
4371                    temp_html.display()
4372                )
4373            })?;
4374            temp_html
4375        };
4376
4377        let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
4378        let cleanup_src = !generate_html;
4379        pdf_path = Some(pdf_dest.clone());
4380        pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
4381    }
4382
4383    let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
4384
4385    Ok((
4386        RunArtifacts {
4387            output_dir: run_dir.to_path_buf(),
4388            html_path,
4389            pdf_path,
4390            json_path,
4391            scan_config_path,
4392            report_title: report_title.to_string(),
4393            result_context,
4394        },
4395        pending_pdf,
4396    ))
4397}
4398
4399/// Find a scan-config JSON file in `dir`, checking both the legacy fixed name and
4400/// the current `scan-config_<stem>.json` pattern for backwards compatibility.
4401fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
4402    let exact = dir.join("scan-config.json");
4403    if exact.exists() {
4404        return Some(exact);
4405    }
4406    fs::read_dir(dir).ok().and_then(|entries| {
4407        entries
4408            .filter_map(|e| e.ok())
4409            .find(|e| {
4410                let name = e.file_name();
4411                let name = name.to_string_lossy();
4412                name.starts_with("scan-config") && name.ends_with(".json")
4413            })
4414            .map(|e| e.path())
4415    })
4416}
4417
4418fn resolve_output_root(raw: Option<&str>) -> PathBuf {
4419    let value = raw.unwrap_or("out/web").trim();
4420    let path = if value.is_empty() {
4421        PathBuf::from("out/web")
4422    } else {
4423        PathBuf::from(value)
4424    };
4425
4426    if path.is_absolute() {
4427        path
4428    } else {
4429        workspace_root().join(path)
4430    }
4431}
4432
4433/// Derive the directory that holds remote-repo clones from the output root.
4434fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
4435    std::env::var("SLOC_GIT_CLONES_DIR")
4436        .map(PathBuf::from)
4437        .unwrap_or_else(|_| output_root.join("git-clones"))
4438}
4439
4440/// Build a deterministic filesystem path for a cloned remote repository.
4441/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
4442pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
4443    let safe: String = repo_url
4444        .chars()
4445        .map(|c| {
4446            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
4447                c
4448            } else {
4449                '_'
4450            }
4451        })
4452        .take(80)
4453        .collect();
4454    clones_dir.join(safe)
4455}
4456
4457/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
4458/// Runs synchronously — call from `tokio::task::spawn_blocking`.
4459pub(crate) fn scan_path_to_artifacts(
4460    scan_path: &Path,
4461    base_config: &AppConfig,
4462    label: &str,
4463) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
4464    let mut config = base_config.clone();
4465    config.discovery.root_paths = vec![scan_path.to_path_buf()];
4466    config.reporting.report_title = label.to_owned();
4467    let run = analyze(&config, "git")?;
4468    let html = render_html(&run)?;
4469    let run_id = run.tool.run_id.clone();
4470    let project_label = sanitize_project_label(label);
4471    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
4472    let file_stem = {
4473        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
4474        if commit.is_empty() {
4475            project_label.clone()
4476        } else {
4477            format!("{project_label}_{commit}")
4478        }
4479    };
4480    let (artifacts, _pending_pdf) = persist_run_artifacts(
4481        &run,
4482        &html,
4483        &output_dir,
4484        true,
4485        true,
4486        false,
4487        label,
4488        &file_stem,
4489        RunResultContext::default(),
4490    )?;
4491    Ok((run_id, artifacts, run))
4492}
4493
4494/// Re-spawn background poll tasks for any polling schedules saved to disk.
4495async fn restart_poll_schedules(state: &AppState) {
4496    let store = state.schedules.lock().await;
4497    let poll_schedules: Vec<_> = store
4498        .schedules
4499        .iter()
4500        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
4501        .cloned()
4502        .collect();
4503    drop(store);
4504    for schedule in poll_schedules {
4505        let interval = schedule.interval_secs.unwrap_or(300);
4506        let st = state.clone();
4507        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
4508    }
4509}
4510
4511fn split_patterns(raw: Option<&str>) -> Vec<String> {
4512    raw.unwrap_or("")
4513        .lines()
4514        .flat_map(|line| line.split(','))
4515        .map(str::trim)
4516        .filter(|part| !part.is_empty())
4517        .map(ToOwned::to_owned)
4518        .collect()
4519}
4520
4521fn build_sub_run(
4522    parent: &AnalysisRun,
4523    sub: &sloc_core::SubmoduleSummary,
4524    parent_path: &str,
4525) -> AnalysisRun {
4526    let sub_files: Vec<_> = parent
4527        .per_file_records
4528        .iter()
4529        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
4530        .cloned()
4531        .collect();
4532    let mut config = parent.effective_configuration.clone();
4533    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
4534    AnalysisRun {
4535        tool: parent.tool.clone(),
4536        environment: parent.environment.clone(),
4537        effective_configuration: config,
4538        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
4539        summary_totals: SummaryTotals {
4540            files_considered: sub.files_analyzed,
4541            files_analyzed: sub.files_analyzed,
4542            files_skipped: 0,
4543            total_physical_lines: sub.total_physical_lines,
4544            code_lines: sub.code_lines,
4545            comment_lines: sub.comment_lines,
4546            blank_lines: sub.blank_lines,
4547            mixed_lines_separate: 0,
4548            functions: 0,
4549            classes: 0,
4550            variables: 0,
4551            imports: 0,
4552        },
4553        totals_by_language: sub.language_summaries.clone(),
4554        per_file_records: sub_files,
4555        skipped_file_records: vec![],
4556        warnings: vec![],
4557        submodule_summaries: vec![],
4558        git_commit_short: parent.git_commit_short.clone(),
4559        git_commit_long: parent.git_commit_long.clone(),
4560        git_branch: parent.git_branch.clone(),
4561        git_commit_author: parent.git_commit_author.clone(),
4562        git_commit_date: parent.git_commit_date.clone(),
4563        git_tags: parent.git_tags.clone(),
4564    }
4565}
4566
4567pub(crate) fn sanitize_project_label(raw: &str) -> String {
4568    let candidate = Path::new(raw)
4569        .file_name()
4570        .and_then(|name| name.to_str())
4571        .unwrap_or("project");
4572
4573    let mut value = String::with_capacity(candidate.len());
4574    for ch in candidate.chars() {
4575        if ch.is_ascii_alphanumeric() {
4576            value.push(ch.to_ascii_lowercase());
4577        } else {
4578            value.push('-');
4579        }
4580    }
4581
4582    let compact = value.trim_matches('-').to_string();
4583    if compact.is_empty() {
4584        "project".to_string()
4585    } else {
4586        compact
4587    }
4588}
4589
4590/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
4591/// comparisons with non-canonicalized stored paths work correctly.
4592fn strip_unc_prefix(path: PathBuf) -> PathBuf {
4593    let s = path.to_string_lossy();
4594    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
4595        return PathBuf::from(format!(r"\\{rest}"));
4596    }
4597    if let Some(rest) = s.strip_prefix(r"\\?\") {
4598        return PathBuf::from(rest);
4599    }
4600    path
4601}
4602
4603fn display_path(path: &Path) -> String {
4604    let s = path.to_string_lossy();
4605    // Strip Windows extended-length prefix for display only; the underlying
4606    // PathBuf remains unchanged so file operations are unaffected.
4607    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
4608    // \\?\C:\path           →  C:\path          (local drive)
4609    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
4610        return format!(r"\\{rest}");
4611    }
4612    if let Some(rest) = s.strip_prefix(r"\\?\") {
4613        return rest.to_owned();
4614    }
4615    s.into_owned()
4616}
4617
4618fn sanitize_path_str(s: &str) -> String {
4619    // Forward-slash variants of the Windows extended-length prefix that appear
4620    // when paths stored as plain strings have been processed through some path
4621    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
4622    if let Some(rest) = s.strip_prefix("//?/UNC/") {
4623        return format!("//{rest}");
4624    }
4625    if let Some(rest) = s.strip_prefix("//?/") {
4626        return rest.to_owned();
4627    }
4628    display_path(Path::new(s))
4629}
4630
4631fn workspace_root() -> PathBuf {
4632    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
4633    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
4634        let p = PathBuf::from(root);
4635        if p.is_dir() {
4636            return p;
4637        }
4638    }
4639
4640    // Current working directory — works for `cargo run` from the project root
4641    // and for scripts/run.sh which cds there first.
4642    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
4643}
4644
4645/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
4646fn make_git_label(repo: &str, ref_name: &str) -> String {
4647    if repo.is_empty() || ref_name.is_empty() {
4648        return String::new();
4649    }
4650    let base = repo
4651        .trim_end_matches('/')
4652        .trim_end_matches(".git")
4653        .rsplit('/')
4654        .next()
4655        .unwrap_or("repo");
4656    let ref_safe: String = ref_name
4657        .chars()
4658        .map(|c| {
4659            if c.is_alphanumeric() || c == '-' || c == '.' {
4660                c
4661            } else {
4662                '_'
4663            }
4664        })
4665        .collect();
4666    format!("{base}_at_{ref_safe}_sloc")
4667}
4668
4669/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
4670fn desktop_dir() -> PathBuf {
4671    if let Ok(profile) = std::env::var("USERPROFILE") {
4672        let p = PathBuf::from(profile).join("Desktop");
4673        if p.exists() {
4674            return p;
4675        }
4676    }
4677    if let Ok(home) = std::env::var("HOME") {
4678        let p = PathBuf::from(home).join("Desktop");
4679        if p.exists() {
4680            return p;
4681        }
4682    }
4683    workspace_root().join("out").join("web")
4684}
4685
4686fn resolve_input_path(raw: &str) -> PathBuf {
4687    let trimmed = raw.trim();
4688    if trimmed.is_empty() {
4689        return workspace_root().join("samples").join("basic");
4690    }
4691
4692    let candidate = PathBuf::from(trimmed);
4693    let resolved = if candidate.is_absolute() {
4694        candidate
4695    } else {
4696        let rooted = workspace_root().join(&candidate);
4697        if rooted.exists() {
4698            rooted
4699        } else {
4700            workspace_root().join(candidate)
4701        }
4702    };
4703
4704    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
4705    // strip that prefix so stored paths and the displayed "Project path" are clean.
4706    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
4707    PathBuf::from(display_path(&canonical))
4708}
4709
4710#[allow(clippy::too_many_lines)]
4711fn build_preview_html(
4712    root: &Path,
4713    include_patterns: &[String],
4714    exclude_patterns: &[String],
4715) -> Result<String> {
4716    if !root.exists() {
4717        return Ok(format!(
4718            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
4719            escape_html(&display_path(root))
4720        ));
4721    }
4722
4723    let _selected = display_path(root);
4724    let mut stats = PreviewStats::default();
4725    let mut rows = Vec::new();
4726    let mut languages = Vec::new();
4727    let mut budget = PreviewBudget {
4728        shown: 0,
4729        max_entries: 600,
4730        max_depth: 9,
4731    };
4732    let mut next_row_id = 1usize;
4733
4734    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
4735        || root.to_string_lossy().into_owned(),
4736        std::string::ToString::to_string,
4737    );
4738    let root_modified = root
4739        .metadata()
4740        .ok()
4741        .and_then(|meta| meta.modified().ok())
4742        .map_or_else(|| "-".to_string(), format_system_time);
4743
4744    rows.push(PreviewRow {
4745        row_id: 0,
4746        parent_row_id: None,
4747        depth: 0,
4748        name: format!("{root_name}/"),
4749        kind: PreviewKind::Dir,
4750        is_dir: true,
4751        language: None,
4752        modified: root_modified,
4753        type_label: "Directory".to_string(),
4754    });
4755    collect_preview_rows(
4756        root,
4757        root,
4758        0,
4759        Some(0),
4760        &mut next_row_id,
4761        &mut budget,
4762        &mut stats,
4763        &mut rows,
4764        &mut languages,
4765        include_patterns,
4766        exclude_patterns,
4767    )?;
4768
4769    let mut out = String::new();
4770    out.push_str(r#"<div class="explorer-wrap">"#);
4771    out.push_str(r#"<div class="explorer-toolbar compact">"#);
4772    out.push_str(r#"<div class="explorer-title-group">"#);
4773    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
4774    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
4775    out.push_str(r"</div></div>");
4776
4777    out.push_str(r#"<div class="scope-stats">"#);
4778    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();
4779    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();
4780    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();
4781    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();
4782    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();
4783    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>"#);
4784    out.push_str(r"</div>");
4785
4786    out.push_str(r#"<div class="scope-info-row">"#);
4787    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
4788    if languages.is_empty() {
4789        out.push_str(
4790            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
4791        );
4792    } else {
4793        out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
4794        for language in &languages {
4795            if let Some(icon) = language_icon_file(language) {
4796                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();
4797            } else if let Some(svg) = language_inline_svg(language) {
4798                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();
4799            } else {
4800                write!(
4801                    out,
4802                    r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
4803                    escape_html(&language.to_ascii_lowercase()),
4804                    escape_html(language)
4805                )
4806                .ok();
4807            }
4808        }
4809    }
4810    out.push_str(r"</div></div>");
4811    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>"#);
4812    out.push_str(r"</div>");
4813
4814    out.push_str(r#"<div class="file-explorer-shell">"#);
4815    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>"#);
4816    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>"#);
4817    out.push_str(r#"<div class="file-explorer-tree">"#);
4818    for row in rows {
4819        let status_label = row.kind.label();
4820        let lang_attr = row.language.unwrap_or("");
4821        let toggle_html = if row.is_dir {
4822            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
4823                .to_string()
4824        } else {
4825            r#"<span class="tree-bullet">•</span>"#.to_string()
4826        };
4827        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();
4828    }
4829    if budget.shown >= budget.max_entries {
4830        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>"#);
4831    }
4832    out.push_str(r"</div></div></div>");
4833
4834    Ok(out)
4835}
4836
4837#[derive(Default)]
4838struct PreviewStats {
4839    directories: usize,
4840    files: usize,
4841    supported: usize,
4842    skipped: usize,
4843    unsupported: usize,
4844}
4845
4846struct PreviewRow {
4847    row_id: usize,
4848    parent_row_id: Option<usize>,
4849    depth: usize,
4850    name: String,
4851    kind: PreviewKind,
4852    is_dir: bool,
4853    language: Option<&'static str>,
4854    modified: String,
4855    type_label: String,
4856}
4857
4858#[derive(Copy, Clone)]
4859enum PreviewKind {
4860    Dir,
4861    Supported,
4862    Skipped,
4863    Unsupported,
4864}
4865
4866impl PreviewKind {
4867    const fn filter_key(self) -> &'static str {
4868        match self {
4869            Self::Dir => "dir",
4870            Self::Supported => "supported",
4871            Self::Skipped => "skipped",
4872            Self::Unsupported => "unsupported",
4873        }
4874    }
4875
4876    const fn label(self) -> &'static str {
4877        match self {
4878            Self::Dir => "dir",
4879            Self::Supported => "supported",
4880            Self::Skipped => "skipped by policy",
4881            Self::Unsupported => "unsupported",
4882        }
4883    }
4884
4885    const fn badge_class(self) -> &'static str {
4886        match self {
4887            Self::Dir => "badge badge-dir",
4888            Self::Supported => "badge badge-scan",
4889            Self::Skipped => "badge badge-skip",
4890            Self::Unsupported => "badge badge-unsupported",
4891        }
4892    }
4893
4894    const fn node_class(self) -> &'static str {
4895        match self {
4896            Self::Dir => "tree-node-dir",
4897            Self::Supported => "tree-node-supported",
4898            Self::Skipped => "tree-node-skipped",
4899            Self::Unsupported => "tree-node-unsupported",
4900        }
4901    }
4902}
4903
4904struct PreviewBudget {
4905    shown: usize,
4906    max_entries: usize,
4907    max_depth: usize,
4908}
4909
4910/// Handle a single directory entry inside `collect_preview_rows`.
4911/// Returns `true` when the entry was handled (caller should `continue`).
4912#[allow(clippy::too_many_arguments)]
4913fn handle_preview_dir_entry(
4914    root: &Path,
4915    path: &Path,
4916    name: &str,
4917    modified: String,
4918    depth: usize,
4919    parent_row_id: Option<usize>,
4920    row_id: usize,
4921    next_row_id: &mut usize,
4922    budget: &mut PreviewBudget,
4923    stats: &mut PreviewStats,
4924    rows: &mut Vec<PreviewRow>,
4925    languages: &mut Vec<&'static str>,
4926    include_patterns: &[String],
4927    exclude_patterns: &[String],
4928) -> Result<()> {
4929    let relative = preview_relative_path(root, path);
4930    if should_skip_preview_directory(&relative, exclude_patterns) {
4931        return Ok(());
4932    }
4933    stats.directories += 1;
4934    rows.push(PreviewRow {
4935        row_id,
4936        parent_row_id,
4937        depth: depth + 1,
4938        name: format!("{name}/"),
4939        kind: PreviewKind::Dir,
4940        is_dir: true,
4941        language: None,
4942        modified,
4943        type_label: "Directory".to_string(),
4944    });
4945    budget.shown += 1;
4946    if !matches!(name, ".git" | "node_modules" | "target") {
4947        collect_preview_rows(
4948            root,
4949            path,
4950            depth + 1,
4951            Some(row_id),
4952            next_row_id,
4953            budget,
4954            stats,
4955            rows,
4956            languages,
4957            include_patterns,
4958            exclude_patterns,
4959        )?;
4960    }
4961    Ok(())
4962}
4963
4964/// Handle a single file entry inside `collect_preview_rows`.
4965#[allow(clippy::too_many_arguments)]
4966fn handle_preview_file_entry(
4967    root: &Path,
4968    path: &Path,
4969    name: &str,
4970    modified: String,
4971    depth: usize,
4972    parent_row_id: Option<usize>,
4973    row_id: usize,
4974    budget: &mut PreviewBudget,
4975    stats: &mut PreviewStats,
4976    rows: &mut Vec<PreviewRow>,
4977    languages: &mut Vec<&'static str>,
4978    include_patterns: &[String],
4979    exclude_patterns: &[String],
4980) {
4981    let relative = preview_relative_path(root, path);
4982    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
4983        return;
4984    }
4985    stats.files += 1;
4986    let kind = classify_preview_file(name);
4987    match kind {
4988        PreviewKind::Supported => stats.supported += 1,
4989        PreviewKind::Skipped => stats.skipped += 1,
4990        PreviewKind::Unsupported => stats.unsupported += 1,
4991        PreviewKind::Dir => {}
4992    }
4993    let language = detect_language_name(name);
4994    if let Some(lang) = language {
4995        if !languages.contains(&lang) {
4996            languages.push(lang);
4997        }
4998    }
4999    rows.push(PreviewRow {
5000        row_id,
5001        parent_row_id,
5002        depth: depth + 1,
5003        name: name.to_owned(),
5004        kind,
5005        is_dir: false,
5006        language,
5007        modified,
5008        type_label: preview_type_label(name, language, kind),
5009    });
5010    budget.shown += 1;
5011}
5012
5013#[allow(clippy::too_many_arguments)]
5014#[allow(clippy::too_many_lines)]
5015fn collect_preview_rows(
5016    root: &Path,
5017    dir: &Path,
5018    depth: usize,
5019    parent_row_id: Option<usize>,
5020    next_row_id: &mut usize,
5021    budget: &mut PreviewBudget,
5022    stats: &mut PreviewStats,
5023    rows: &mut Vec<PreviewRow>,
5024    languages: &mut Vec<&'static str>,
5025    include_patterns: &[String],
5026    exclude_patterns: &[String],
5027) -> Result<()> {
5028    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
5029        return Ok(());
5030    }
5031
5032    let mut entries = fs::read_dir(dir)
5033        .with_context(|| format!("failed to read directory {}", dir.display()))?
5034        .filter_map(std::result::Result::ok)
5035        .collect::<Vec<_>>();
5036    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
5037
5038    for entry in entries {
5039        if budget.shown >= budget.max_entries {
5040            break;
5041        }
5042
5043        let path = entry.path();
5044        let name = entry.file_name().to_string_lossy().into_owned();
5045        let Ok(metadata) = entry.metadata() else {
5046            continue;
5047        };
5048        let row_id = *next_row_id;
5049        *next_row_id += 1;
5050        let modified = metadata
5051            .modified()
5052            .ok()
5053            .map_or_else(|| "-".to_string(), format_system_time);
5054
5055        if metadata.is_dir() {
5056            handle_preview_dir_entry(
5057                root,
5058                &path,
5059                &name,
5060                modified,
5061                depth,
5062                parent_row_id,
5063                row_id,
5064                next_row_id,
5065                budget,
5066                stats,
5067                rows,
5068                languages,
5069                include_patterns,
5070                exclude_patterns,
5071            )?;
5072            continue;
5073        }
5074
5075        if metadata.is_file() {
5076            handle_preview_file_entry(
5077                root,
5078                &path,
5079                &name,
5080                modified,
5081                depth,
5082                parent_row_id,
5083                row_id,
5084                budget,
5085                stats,
5086                rows,
5087                languages,
5088                include_patterns,
5089                exclude_patterns,
5090            );
5091        }
5092    }
5093
5094    Ok(())
5095}
5096
5097fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
5098    if let Some(language) = language {
5099        return format!("{language} source");
5100    }
5101    let lower = name.to_ascii_lowercase();
5102    let ext = Path::new(&lower)
5103        .extension()
5104        .and_then(|e| e.to_str())
5105        .unwrap_or("");
5106    match kind {
5107        PreviewKind::Skipped => {
5108            if lower.ends_with(".min.js") {
5109                "Minified asset".to_string()
5110            } else if [
5111                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
5112            ]
5113            .contains(&ext)
5114            {
5115                "Binary or archive".to_string()
5116            } else {
5117                "Skipped file".to_string()
5118            }
5119        }
5120        PreviewKind::Unsupported => {
5121            if ext.is_empty() {
5122                "Unsupported file".to_string()
5123            } else {
5124                format!("{} file", ext.to_ascii_uppercase())
5125            }
5126        }
5127        PreviewKind::Supported => "Supported source".to_string(),
5128        PreviewKind::Dir => "Directory".to_string(),
5129    }
5130}
5131
5132fn format_system_time(time: SystemTime) -> String {
5133    #[allow(clippy::cast_possible_wrap)]
5134    let secs = match time.duration_since(UNIX_EPOCH) {
5135        Ok(duration) => duration.as_secs() as i64,
5136        Err(_) => return "-".to_string(),
5137    };
5138    let days = secs.div_euclid(86_400);
5139    let secs_of_day = secs.rem_euclid(86_400);
5140    let (year, month, day) = civil_from_days(days);
5141    let hour = secs_of_day / 3_600;
5142    let minute = (secs_of_day % 3_600) / 60;
5143    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
5144}
5145
5146#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5147fn civil_from_days(days: i64) -> (i32, u32, u32) {
5148    let z = days + 719_468;
5149    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
5150    let doe = z - era * 146_097;
5151    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
5152    let y = yoe + era * 400;
5153    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
5154    let mp = (5 * doy + 2) / 153;
5155    let d = doy - (153 * mp + 2) / 5 + 1;
5156    let m = mp + if mp < 10 { 3 } else { -9 };
5157    let year = y + i64::from(m <= 2);
5158    (year as i32, m as u32, d as u32)
5159}
5160
5161// The input is already lowercased via `to_ascii_lowercase()` before calling
5162// `ends_with`, so the comparisons are inherently case-insensitive.
5163#[allow(clippy::case_sensitive_file_extension_comparisons)]
5164fn detect_language_name(name: &str) -> Option<&'static str> {
5165    let lower = name.to_ascii_lowercase();
5166    if lower.ends_with(".c") || lower.ends_with(".h") {
5167        Some("C")
5168    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
5169        .iter()
5170        .any(|s| lower.ends_with(s))
5171    {
5172        Some("C++")
5173    } else if lower.ends_with(".cs") {
5174        Some("C#")
5175    } else if lower.ends_with(".py") {
5176        Some("Python")
5177    } else if lower.ends_with(".sh") {
5178        Some("Shell")
5179    } else if [".ps1", ".psm1", ".psd1"]
5180        .iter()
5181        .any(|s| lower.ends_with(s))
5182    {
5183        Some("PowerShell")
5184    } else {
5185        None
5186    }
5187}
5188
5189fn language_icon_file(language: &str) -> Option<&'static str> {
5190    match language {
5191        "C" => Some("c.png"),
5192        "C++" => Some("cpp.png"),
5193        "C#" => Some("c-sharp.png"),
5194        "Python" => Some("python.png"),
5195        "Shell" => Some("shell.png"),
5196        "PowerShell" => Some("powershell.png"),
5197        "JavaScript" => Some("java-script.png"),
5198        "HTML" => Some("html-5.png"),
5199        "Java" => Some("java.png"),
5200        "Visual Basic" => Some("visual-basic.png"),
5201        "Assembly" => Some("asm.png"),
5202        "Go" => Some("go.png"),
5203        "R" => Some("r.png"),
5204        "XML" => Some("xml.png"),
5205        "Groovy" => Some("groovy.png"),
5206        "Dockerfile" => Some("docker.png"),
5207        "Makefile" => Some("makefile.svg"),
5208        "Perl" => Some("perl.svg"),
5209        _ => None,
5210    }
5211}
5212
5213// Inline SVG badges for languages that have no PNG icon in images/icons/.
5214// Using inline SVG keeps the web UI fully self-contained — no extra files
5215// needed on disk, no 404s on air-gapped deployments.
5216// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
5217fn language_inline_svg(language: &str) -> Option<&'static str> {
5218    match language {
5219        "Rust" => Some(
5220            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>"##,
5221        ),
5222        "TypeScript" => Some(
5223            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>"##,
5224        ),
5225        _ => None,
5226    }
5227}
5228
5229// The input is already lowercased via `to_ascii_lowercase()` before the
5230// `ends_with` calls, so these comparisons are inherently case-insensitive.
5231#[allow(clippy::case_sensitive_file_extension_comparisons)]
5232fn classify_preview_file(name: &str) -> PreviewKind {
5233    let lower = name.to_ascii_lowercase();
5234
5235    let scannable = [
5236        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
5237        ".psm1", ".psd1",
5238    ]
5239    .iter()
5240    .any(|suffix| lower.ends_with(suffix));
5241
5242    if scannable {
5243        PreviewKind::Supported
5244    } else if lower.ends_with(".min.js")
5245        || lower.ends_with(".lock")
5246        || lower.ends_with(".png")
5247        || lower.ends_with(".jpg")
5248        || lower.ends_with(".jpeg")
5249        || lower.ends_with(".gif")
5250        || lower.ends_with(".zip")
5251        || lower.ends_with(".pdf")
5252        || lower.ends_with(".pyc")
5253        || lower.ends_with(".xz")
5254        || lower.ends_with(".tar")
5255        || lower.ends_with(".gz")
5256    {
5257        PreviewKind::Skipped
5258    } else {
5259        PreviewKind::Unsupported
5260    }
5261}
5262
5263fn preview_relative_path(root: &Path, path: &Path) -> String {
5264    path.strip_prefix(root)
5265        .ok()
5266        .unwrap_or(path)
5267        .to_string_lossy()
5268        .replace('\\', "/")
5269        .trim_matches('/')
5270        .to_string()
5271}
5272
5273fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
5274    if relative.is_empty() {
5275        return false;
5276    }
5277
5278    exclude_patterns.iter().any(|pattern| {
5279        wildcard_match(pattern, relative)
5280            || wildcard_match(pattern, &format!("{relative}/"))
5281            || wildcard_match(pattern, &format!("{relative}/placeholder"))
5282    })
5283}
5284
5285fn should_include_preview_file(
5286    relative: &str,
5287    include_patterns: &[String],
5288    exclude_patterns: &[String],
5289) -> bool {
5290    if relative.is_empty() {
5291        return true;
5292    }
5293
5294    let included = include_patterns.is_empty()
5295        || include_patterns
5296            .iter()
5297            .any(|pattern| wildcard_match(pattern, relative));
5298    let excluded = exclude_patterns
5299        .iter()
5300        .any(|pattern| wildcard_match(pattern, relative));
5301
5302    included && !excluded
5303}
5304
5305fn wildcard_match(pattern: &str, candidate: &str) -> bool {
5306    let pattern = pattern.trim().replace('\\', "/");
5307    let candidate = candidate.trim().replace('\\', "/");
5308    let p = pattern.as_bytes();
5309    let c = candidate.as_bytes();
5310    let mut pi = 0usize;
5311    let mut ci = 0usize;
5312    let mut star: Option<usize> = None;
5313    let mut star_match = 0usize;
5314
5315    while ci < c.len() {
5316        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
5317            pi += 1;
5318            ci += 1;
5319        } else if pi < p.len() && p[pi] == b'*' {
5320            while pi < p.len() && p[pi] == b'*' {
5321                pi += 1;
5322            }
5323            star = Some(pi);
5324            star_match = ci;
5325        } else if let Some(star_pi) = star {
5326            star_match += 1;
5327            ci = star_match;
5328            pi = star_pi;
5329        } else {
5330            return false;
5331        }
5332    }
5333
5334    while pi < p.len() && p[pi] == b'*' {
5335        pi += 1;
5336    }
5337
5338    pi == p.len()
5339}
5340
5341fn escape_html(value: &str) -> String {
5342    value
5343        .replace('&', "&amp;")
5344        .replace('<', "&lt;")
5345        .replace('>', "&gt;")
5346        .replace('"', "&quot;")
5347        .replace('\'', "&#39;")
5348}
5349
5350#[derive(Clone)]
5351struct LanguageSummaryRow {
5352    language: String,
5353    files: u64,
5354    physical: u64,
5355    code: u64,
5356    comments: u64,
5357    blank: u64,
5358    mixed: u64,
5359    functions: u64,
5360    classes: u64,
5361    variables: u64,
5362    imports: u64,
5363}
5364
5365#[derive(Clone)]
5366struct SubmoduleRow {
5367    name: String,
5368    relative_path: String,
5369    files_analyzed: u64,
5370    code_lines: u64,
5371    comment_lines: u64,
5372    blank_lines: u64,
5373    total_physical_lines: u64,
5374    html_url: Option<String>,
5375}
5376
5377#[derive(Template)]
5378#[template(
5379    source = r##"
5380<!doctype html>
5381<html lang="en">
5382<head>
5383  <meta charset="utf-8">
5384  <title>OxideSLOC | tmp-sloc</title>
5385  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5386  <style nonce="{{ csp_nonce }}">
5387    :root {
5388      --bg: #efe9e2;
5389      --surface: #fcfaf7;
5390      --surface-2: #f7f0e8;
5391      --surface-3: #efe3d5;
5392      --line: #dfcfbf;
5393      --line-strong: #cfb29c;
5394      --text: #2f241c;
5395      --muted: #6f6257;
5396      --muted-2: #917f71;
5397      --nav: #b85d33;
5398      --nav-2: #7a371b;
5399      --accent: #2563eb;
5400      --accent-2: #1d4ed8;
5401      --oxide: #b85d33;
5402      --oxide-2: #8f4220;
5403      --success-bg: #eaf9ee;
5404      --success-text: #1c8746;
5405      --warn-bg: #fff2d8;
5406      --warn-text: #926000;
5407      --danger-bg: #fdeaea;
5408      --danger-text: #b33b3b;
5409      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
5410      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
5411      --radius: 14px;
5412    }
5413
5414    body.dark-theme {
5415      --bg: #1b1511;
5416      --surface: #261c17;
5417      --surface-2: #2d221d;
5418      --surface-3: #372922;
5419      --line: #524238;
5420      --line-strong: #6c5649;
5421      --text: #f5ece6;
5422      --muted: #c7b7aa;
5423      --muted-2: #aa9485;
5424      --nav: #b85d33;
5425      --nav-2: #7a371b;
5426      --accent: #6f9bff;
5427      --accent-2: #4a78ee;
5428      --oxide: #d37a4c;
5429      --oxide-2: #b35428;
5430      --success-bg: #163927;
5431      --success-text: #8fe2a8;
5432      --warn-bg: #3c2d11;
5433      --warn-text: #f3cb75;
5434      --danger-bg: #3d1f1f;
5435      --danger-text: #ff9f9f;
5436      --shadow: 0 14px 28px rgba(0,0,0,0.28);
5437      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
5438    }
5439
5440    * { box-sizing: border-box; }
5441    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); }
5442    html { overflow-y: scroll; }
5443    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
5444    .top-nav, .page, .loading { position: relative; z-index: 2; }
5445    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
5446    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
5447    .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); }
5448    .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 18px; }
5449    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
5450    .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)); }
5451    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
5452    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
5453    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
5454    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
5455    .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; }
5456    .nav-project-pill.visible { display:inline-flex; }
5457    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
5458    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
5459    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: wrap; }
5460    .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); text-decoration:none; transition:background .15s ease,transform .15s ease; }
5461    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
5462    .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; }
5463    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
5464    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
5465    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
5466    .theme-toggle .icon-sun { display:none; }
5467    body.dark-theme .theme-toggle .icon-sun { display:block; }
5468    body.dark-theme .theme-toggle .icon-moon { display:none; }
5469    .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; }
5470    .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;}
5471    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
5472    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
5473    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
5474    .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
5475    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
5476    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; }
5477    .wb-stats-header { padding: 10px 24px 0; }
5478    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
5479    .ws-left { display:flex; align-items:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
5480    .ws-stat { display:flex; flex-direction:column; 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); }
5481    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
5482    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
5483    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
5484    .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; }
5485    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
5486    .ws-lang-tooltip { display:none; position:absolute; top:calc(100% + 8px); left:0; z-index:200; 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; }
5487    .ws-badge:hover .ws-lang-tooltip { display:block; }
5488    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:9px; }
5489    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
5490    .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; }
5491    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
5492    .ws-divider { display: none; }
5493    .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%; }
5494    .ws-path-link:hover { color:var(--oxide); }
5495    body.dark-theme .ws-path-link { color:var(--oxide); }
5496    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
5497    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
5498    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
5499    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
5500    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
5501    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
5502    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
5503    .ws-mini-box-lg { flex:2 1 0; }
5504    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
5505    .ws-mini-box-br { flex:1.5 1 0; }
5506    .scope-legend-row { display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap; 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); }
5507    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
5508    .path-scope-grid { display:grid; grid-template-columns: 1fr 1px auto; gap:0; align-items:stretch; }
5509    .path-scope-grid .input-group { width:100%; align-self:start; }
5510    .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; }
5511    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
5512    .git-source-banner strong { font-weight:800; color:var(--text); }
5513    .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; }
5514    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
5515    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
5516    .git-source-banner a:hover { text-decoration:underline; }
5517    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
5518    .path-scope-sep { background:var(--line); margin:4px 14px; }
5519    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
5520    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
5521    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
5522    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
5523    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
5524    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
5525    .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; }
5526    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
5527    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
5528    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
5529    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
5530    .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; }
5531    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
5532    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
5533    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
5534    .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; }
5535    .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); }
5536    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
5537    .side-info-card { padding: 18px; }
5538    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
5539    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
5540    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
5541    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
5542    .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); }
5543    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
5544    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
5545    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
5546    .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; }
5547    .layout { display:grid; grid-template-columns: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
5548    .layout[data-active-step="4"] { align-items: start; min-height: auto; }
5549    .side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; width: 218px; max-width: 218px; }
5550    .step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
5551    .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); }
5552    .step-button { width:100%; display:flex; align-items:center; gap:12px; border:none; background:transparent; border-radius: 12px; padding: 14px 12px; color: var(--text); cursor:pointer; text-align:left; font-size:15px; font-weight:700; transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; animation: stepEntrance 0.3s ease both; }
5553    .step-button:hover { background: var(--surface-2); }
5554    .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); }
5555    .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; }
5556    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
5557    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
5558    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
5559    .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); }
5560    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
5561    .step-nav-sum-row:last-child { border-bottom:none; }
5562    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
5563    .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; }
5564    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
5565    .quick-scan-section { padding: 10px 4px 14px; }
5566    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
5567    .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; }
5568    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
5569    .quick-scan-btn:active { transform:translateY(0); }
5570    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
5571    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
5572    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
5573    @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);} }
5574    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
5575    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
5576    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
5577    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
5578    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
5579    .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; }
5580    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
5581    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
5582    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
5583    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
5584    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
5585    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
5586    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
5587    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
5588    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
5589    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
5590    .card-body { padding: 22px; }
5591    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
5592    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
5593    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
5594    .section { margin-bottom: 22px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
5595    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
5596    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
5597    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
5598    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
5599    .field { min-width:0; }
5600    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
5601    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; }
5602    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); }
5603    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
5604    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); }
5605    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
5606    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
5607    .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; }
5608    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
5609    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
5610    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
5611    .input-group.compact { grid-template-columns: 1fr auto auto; }
5612    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
5613    .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)); }
5614    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
5615    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
5616    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
5617    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
5618    .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; }
5619    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
5620    .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; }
5621    .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); }
5622    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
5623    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
5624    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
5625    button.secondary { background: var(--surface); }
5626    .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); }
5627    .section + .wizard-actions { border-top: none; padding-top: 0; }
5628    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
5629    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
5630    .field-help-grid.coupled-help { margin-top: 12px; }
5631    .field-help-grid.preset-grid { align-items: start; }
5632    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:stretch; margin-bottom: 16px; }
5633    .preset-inline-row .field { margin: 0; }
5634    .preset-inline-row .explainer-card { margin: 0; }
5635    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
5636    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
5637    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
5638    .output-field-row .field { margin: 0; }
5639    .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; }
5640    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
5641    .step3-subtitle { margin-bottom: 28px; }
5642    .counting-intro { margin-bottom: 8px; max-width: none; }
5643    .ieee-note { margin-bottom: 22px; padding: 9px 14px; border-left: 3px solid var(--oxide); background: linear-gradient(180deg, rgba(184,93,51,0.07), transparent), var(--surface-2); border-radius: 0 8px 8px 0; font-size: 13px; color: var(--muted); line-height: 1.5; font-style: italic; }
5644    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
5645    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
5646    .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; }
5647    .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; }
5648    .section-spacer-top { margin-top: 28px; }
5649    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
5650    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
5651    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
5652    .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); }
5653    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
5654    .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; }
5655    .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; }
5656    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
5657    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
5658    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
5659    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
5660    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
5661    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
5662    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
5663    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; }
5664    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
5665    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
5666    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
5667    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
5668    .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); }
5669    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
5670    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
5671    .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; }
5672    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
5673    .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; }
5674    .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; }
5675    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
5676    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
5677    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
5678    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
5679    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
5680    .advanced-rule-description strong { color: var(--text); }
5681    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
5682    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
5683    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
5684    .review-link:hover { text-decoration: underline; }
5685    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
5686    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
5687    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
5688    .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; }
5689    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
5690    .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; }
5691    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
5692    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
5693    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
5694    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
5695    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
5696    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
5697    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
5698    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
5699    .review-card ul { padding-left: 18px; margin: 0; }
5700    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
5701    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
5702    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
5703    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
5704    .review-card { min-height: 200px; }
5705    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
5706    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
5707    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
5708    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
5709    .lang-overflow-chip { position:relative; cursor:default; }
5710    .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; }
5711    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
5712    .git-inline-row { align-items:start; }
5713    .mixed-line-card { display:flex; flex-direction:column; }
5714    .preset-inline-row .toggle-card { justify-content: center; }
5715        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
5716    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
5717    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
5718    .explorer-title { font-size: 18px; font-weight: 850; }
5719    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
5720    .explorer-subtitle.wide { max-width: none; }
5721    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
5722    .better-spacing { align-items:flex-start; justify-content:flex-end; }
5723    .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; }
5724    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
5725    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
5726    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
5727    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
5728    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
5729    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
5730    .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; }
5731    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
5732    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
5733    .scope-stat-button.supported { background: var(--success-bg); }
5734    .scope-stat-button.skipped { background: var(--warn-bg); }
5735    .scope-stat-button.unsupported { background: var(--danger-bg); }
5736    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
5737    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
5738    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
5739    [data-tooltip] { position: relative; }
5740    [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); }
5741    [data-tooltip]:hover::after { display: block; }
5742    .scope-stat-button[data-tooltip] { cursor: pointer; }
5743    .badge[data-tooltip] { cursor: help; }
5744    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
5745    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
5746    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
5747    .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; }
5748    .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; }
5749    code { display:inline-block; margin-top:0; padding:2px 7px; }
5750    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
5751    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
5752    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
5753    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
5754    .language-pill.muted-pill { color: var(--muted); }
5755    button.language-pill { appearance:none; cursor:pointer; }
5756    .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); }
5757    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
5758    .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; }
5759    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
5760    .file-explorer-search-row { margin-left: auto; }
5761    .explorer-filter-select { min-width: 170px; width: 170px; }
5762    .explorer-search { min-width: 300px; width: 300px; }
5763    .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); }
5764    .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; }
5765    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
5766    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
5767    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
5768    .file-explorer-tree { max-height: 560px; overflow:auto; }
5769    .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); }
5770    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
5771    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
5772    .tree-row.hidden-by-filter { display:none !important; }
5773    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 9px 0; }
5774    .tree-name-cell { display:flex; align-items:center; gap: 10px; padding-left: calc(var(--depth) * 18px + 8px); position: relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; min-width:0; }
5775    .tree-toggle { width: 28px; height: 28px; display:inline-flex; align-items:center; justify-content:center; border:none; background: var(--surface-2); color: var(--muted-2); cursor:pointer; font-size: 18px; line-height: 1; flex:0 0 28px; border-radius: 8px; border: 1px solid var(--line); font-weight: 900; }
5776    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
5777    .tree-bullet { color: var(--muted-2); width: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
5778    .tree-node { display:inline-flex; align-items:center; min-width:0; }
5779    .tree-node-dir { color: var(--text); font-weight: 800; }
5780    .tree-node-supported { color: var(--success-text); }
5781    .tree-node-skipped { color: var(--warn-text); }
5782    .tree-node-unsupported { color: var(--danger-text); }
5783    .tree-node-more { color: var(--muted-2); font-style: italic; }
5784    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 13px; }
5785    .tree-status-cell { display:flex; justify-content:flex-start; }
5786    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
5787    .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); }
5788    .loading.active { display:flex; }
5789    .loading-card { width: min(560px, 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: 28px 32px; }
5790    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
5791    .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; }
5792    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
5793    .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; }
5794    .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; }
5795    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
5796    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
5797    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
5798    .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; }
5799    .lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
5800    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 16px;flex:0 0 auto; }
5801    .lc-metric-label { font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px; }
5802    .lc-metric-value { font-size:1.05rem;font-weight:700;color:var(--text); }
5803    .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; }
5804    .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; }
5805    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
5806    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
5807    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
5808    .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; }
5809    .hidden { display:none !important; }
5810    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
5811    .site-footer a{color:var(--muted);}
5812    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
5813    @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; } }
5814    .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;}
5815    @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));}}
5816    .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;}.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;}
5817  </style>
5818</head>
5819<body>
5820  <div class="background-watermarks" aria-hidden="true">
5821    <img src="/images/logo/logo-text.png" alt="" />
5822    <img src="/images/logo/logo-text.png" alt="" />
5823    <img src="/images/logo/logo-text.png" alt="" />
5824    <img src="/images/logo/logo-text.png" alt="" />
5825    <img src="/images/logo/logo-text.png" alt="" />
5826    <img src="/images/logo/logo-text.png" alt="" />
5827    <img src="/images/logo/logo-text.png" alt="" />
5828    <img src="/images/logo/logo-text.png" alt="" />
5829    <img src="/images/logo/logo-text.png" alt="" />
5830    <img src="/images/logo/logo-text.png" alt="" />
5831    <img src="/images/logo/logo-text.png" alt="" />
5832    <img src="/images/logo/logo-text.png" alt="" />
5833    <img src="/images/logo/logo-text.png" alt="" />
5834    <img src="/images/logo/logo-text.png" alt="" />
5835  </div>
5836  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5837  <div class="top-nav">
5838    <div class="top-nav-inner">
5839      <a class="brand" href="/">
5840        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
5841        <div class="brand-copy">
5842          <div class="brand-title">OxideSLOC</div>
5843          <div class="brand-subtitle">Local analysis workbench</div>
5844        </div>
5845      </a>
5846      <div class="nav-project-slot">
5847        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
5848          <span class="nav-project-label">Project</span>
5849          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
5850        </div>
5851      </div>
5852      <div class="nav-status">
5853        <a class="nav-pill" href="/">Home</a>
5854        <a class="nav-pill" href="/view-reports">View Reports</a>
5855        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5856        <div class="nav-dropdown">
5857          <button class="nav-dropdown-btn" type="button">Git Tools <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></button>
5858          <div class="nav-dropdown-menu">
5859            <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
5860            <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
5861          </div>
5862        </div>
5863        <div class="server-status-wrap">
5864          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
5865          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
5866        </div>
5867        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
5868          <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>
5869          <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>
5870        </button>
5871      </div>
5872    </div>
5873  </div>
5874
5875  <div class="loading" id="loading">
5876    <div class="loading-card">
5877      <div class="lc-badge"><span class="lc-dot"></span>Analysis running</div>
5878      <h2 class="lc-title">Analyzing your project…</h2>
5879      <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
5880      <div class="lc-path" id="lc-path"></div>
5881      <div class="lc-metrics">
5882        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
5883        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
5884      </div>
5885      <div class="progress-bar"><span></span></div>
5886      <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>
5887      <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>
5888      <div class="lc-actions hidden" id="lc-actions">
5889        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
5890        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
5891      </div>
5892    </div>
5893  </div>
5894
5895  <div class="page">
5896    <div class="workbench-strip">
5897      <div class="workbench-box wb-stats">
5898        <div class="wb-stats-header">
5899          <span class="wb-stats-title">Analysis session</span>
5900        </div>
5901        <div class="ws-left">
5902          <div class="ws-stat">
5903            <span class="ws-label">Analyzers</span>
5904            <span class="ws-value">
5905              <span class="ws-badge">41 languages
5906                <div class="ws-lang-tooltip">
5907                  <div class="ws-lang-tooltip-hdr">41 supported languages</div>
5908                  <div class="ws-lang-grid">
5909                    <span class="ws-lang-item">Assembly</span>
5910                    <span class="ws-lang-item">C</span>
5911                    <span class="ws-lang-item">C++</span>
5912                    <span class="ws-lang-item">C#</span>
5913                    <span class="ws-lang-item">Clojure</span>
5914                    <span class="ws-lang-item">CSS</span>
5915                    <span class="ws-lang-item">Dart</span>
5916                    <span class="ws-lang-item">Dockerfile</span>
5917                    <span class="ws-lang-item">Elixir</span>
5918                    <span class="ws-lang-item">Erlang</span>
5919                    <span class="ws-lang-item">F#</span>
5920                    <span class="ws-lang-item">Go</span>
5921                    <span class="ws-lang-item">Groovy</span>
5922                    <span class="ws-lang-item">Haskell</span>
5923                    <span class="ws-lang-item">HTML</span>
5924                    <span class="ws-lang-item">Java</span>
5925                    <span class="ws-lang-item">JavaScript</span>
5926                    <span class="ws-lang-item">Julia</span>
5927                    <span class="ws-lang-item">Kotlin</span>
5928                    <span class="ws-lang-item">Lua</span>
5929                    <span class="ws-lang-item">Makefile</span>
5930                    <span class="ws-lang-item">Nim</span>
5931                    <span class="ws-lang-item">Obj-C</span>
5932                    <span class="ws-lang-item">OCaml</span>
5933                    <span class="ws-lang-item">Perl</span>
5934                    <span class="ws-lang-item">PHP</span>
5935                    <span class="ws-lang-item">PowerShell</span>
5936                    <span class="ws-lang-item">Python</span>
5937                    <span class="ws-lang-item">R</span>
5938                    <span class="ws-lang-item">Ruby</span>
5939                    <span class="ws-lang-item">Rust</span>
5940                    <span class="ws-lang-item">Scala</span>
5941                    <span class="ws-lang-item">SCSS</span>
5942                    <span class="ws-lang-item">Shell</span>
5943                    <span class="ws-lang-item">SQL</span>
5944                    <span class="ws-lang-item">Svelte</span>
5945                    <span class="ws-lang-item">Swift</span>
5946                    <span class="ws-lang-item">TypeScript</span>
5947                    <span class="ws-lang-item">Vue</span>
5948                    <span class="ws-lang-item">XML</span>
5949                    <span class="ws-lang-item">Zig</span>
5950                  </div>
5951                </div>
5952              </span>
5953            </span>
5954          </div>
5955          <div class="ws-divider"></div>
5956          <div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
5957          <div class="ws-divider"></div>
5958          <div class="ws-stat ws-stat-clamp"><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
5959          <div class="ws-divider"></div>
5960          <div class="ws-stat ws-stat-output">
5961            <span class="ws-label">Output</span>
5962            <span class="ws-value">
5963              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
5964                <span id="ws-output-root">project/sloc</span>
5965              </button>
5966            </span>
5967          </div>
5968        </div>
5969      </div>
5970      <div class="workbench-box ws-history-group">
5971        <div class="ws-history-label">Scan history</div>
5972        <div class="ws-history-inner">
5973          <div class="ws-mini-box ws-mini-box-sm">
5974            <div class="ws-mini-label">Scans</div>
5975            <div class="ws-mini-value" id="ws-scan-count">—</div>
5976          </div>
5977          <div class="ws-mini-box ws-mini-box-lg">
5978            <div class="ws-mini-label">Last Scan</div>
5979            <div class="ws-mini-value" id="ws-last-scan">—</div>
5980          </div>
5981          <div class="ws-mini-box ws-mini-box-br">
5982            <div class="ws-mini-label">Branch</div>
5983            <div class="ws-mini-value" id="ws-branch">—</div>
5984          </div>
5985        </div>
5986      </div>
5987    </div>
5988
5989    <div class="layout">
5990      <aside class="side-stack">
5991        <section class="step-nav">
5992        <h3>Guided scan setup</h3>
5993        <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span></button>
5994        <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
5995        <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
5996        <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
5997
5998        <div class="step-nav-info" id="step-nav-info">
5999          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
6000          <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>
6001        </div>
6002
6003        <div class="quick-scan-divider"></div>
6004        <div class="quick-scan-section">
6005          <div class="quick-scan-label">No customization needed?</div>
6006          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
6007            <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>
6008            Quick Scan
6009          </button>
6010          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
6011        </div>
6012        </section>
6013
6014      </aside>
6015
6016      <section class="card">
6017        <div class="card-header">
6018          <div class="card-title-row">
6019            <div>
6020              <h1 class="card-title">Guided scan configuration</h1>
6021              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
6022            </div>
6023            <div class="wizard-progress" aria-label="Scan setup progress">
6024              <div class="wizard-progress-top">
6025                <span class="wizard-progress-label">Setup progress</span>
6026                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
6027              </div>
6028              <div class="wizard-progress-track">
6029                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
6030              </div>
6031            </div>
6032          </div>
6033        </div>
6034        <div class="card-body">
6035          <form method="post" action="/analyze" id="analyze-form">
6036            <div class="wizard-step active" data-step="1">
6037              <div class="section">
6038                <div class="section-kicker">Step 1</div>
6039                <h2>Select project and preview scope</h2>
6040                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
6041                <div class="field">
6042                  <label for="path">Project path</label>
6043                  {% if !git_repo.is_empty() %}
6044                  <div class="git-source-banner">
6045                    <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>
6046                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
6047                    <a href="/git-browser">← Back to Git Browser</a>
6048                  </div>
6049                  {% endif %}
6050                  <div class="path-scope-grid">
6051                    <div class="input-group">
6052                      {% if !git_repo.is_empty() %}
6053                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required />
6054                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
6055                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
6056                      {% else %}
6057                      <input id="path" name="path" type="text" value="tmp-sloc" placeholder="/path/to/repository" required />
6058                      <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
6059                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
6060                      {% endif %}
6061                    </div>
6062                    <div class="path-scope-sep"></div>
6063                    <div class="scope-legend-row">
6064                      <span class="scope-legend-label">Scope legend:</span>
6065                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
6066                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
6067                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
6068                    </div>
6069                  </div>
6070                  {% if git_repo.is_empty() %}
6071                  <div class="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</div>
6072                  {% else %}
6073                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
6074                  {% endif %}
6075                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
6076                </div>
6077
6078                <div style="height:1px;background:var(--line);margin:28px 0;"></div>
6079
6080                <div id="preview-panel" style="margin-top:0;">
6081                  <div class="preview-error">Loading preview...</div>
6082                </div>
6083              </div>
6084
6085              <div class="section">
6086                <div class="field-grid">
6087                  <div class="field">
6088                    <label for="include_globs">Include globs</label>
6089                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
6090                    <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>
6091                  </div>
6092                  <div class="field">
6093                    <label for="exclude_globs">Exclude globs</label>
6094                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
6095                    <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>
6096                  </div>
6097                </div>
6098                <div class="glob-guidance-grid">
6099                  <div class="glob-guidance-card">
6100                    <strong>How to read them</strong>
6101                    <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>
6102                  </div>
6103                  <div class="glob-guidance-card">
6104                    <strong>Common include examples</strong>
6105                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
6106                  </div>
6107                  <div class="glob-guidance-card">
6108                    <strong>Common exclude examples</strong>
6109                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
6110                  </div>
6111                </div>
6112              </div>
6113
6114              <div class="section" style="margin-top:14px;">
6115                <div class="preset-inline-row git-inline-row">
6116                  <div class="toggle-card" style="margin:0;">
6117                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
6118                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
6119                    <label class="checkbox">
6120                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
6121                      <div>
6122                        <span>Detect and separate git submodules</span>
6123                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
6124                      </div>
6125                    </label>
6126                  </div>
6127                  <div class="explainer-card prominent" style="margin:0;">
6128                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
6129                    <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>
6130                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
6131    path = libs/core
6132    url  = https://github.com/org/core.git
6133
6134[submodule "libs/ui"]
6135    path = libs/ui
6136    url  = https://github.com/org/ui.git</div>
6137                  </div>
6138                </div>
6139              </div>
6140
6141              <div class="wizard-actions">
6142                <div class="left"></div>
6143                <div class="right">
6144                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
6145                </div>
6146              </div>
6147            </div>
6148
6149            <div class="wizard-step" data-step="2">
6150              <div class="section">
6151                <div class="section-kicker">Step 2</div>
6152                <h2>Choose counting behavior</h2>
6153                <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>
6154                <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
6155                <div class="subsection-bar">Primary line classification</div>
6156                <div class="preset-inline-row" style="align-items:start;">
6157                  <div class="toggle-card mixed-line-card" style="margin:0;">
6158                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
6159                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
6160                    <select id="mixed_line_policy" name="mixed_line_policy">
6161                      <option value="code_only">Code only</option>
6162                      <option value="code_and_comment">Code and comment</option>
6163                      <option value="comment_only">Comment only</option>
6164                      <option value="separate_mixed_category">Separate mixed category</option>
6165                    </select>
6166                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
6167                  </div>
6168                  <div class="explainer-card prominent" style="margin:0;">
6169                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
6170                    <div class="explainer-body" id="mixed-policy-description"></div>
6171                    <div class="code-sample" id="mixed-policy-example"></div>
6172                  </div>
6173                </div>
6174              </div>
6175
6176              <div class="subsection-bar">Additional scan rules</div>
6177              <div class="scan-rules-grid">
6178                <div class="preset-inline-row">
6179                  <div class="toggle-card" style="margin:0;">
6180                    <div class="field-help-title">Generated files</div>
6181                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
6182                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
6183                  </div>
6184                  <div class="explainer-card prominent" style="margin:0;">
6185                    <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>
6186                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
6187# Files matching codegen patterns are excluded:
6188#   *.generated.cs  *.pb.go  *.g.dart</div>
6189                  </div>
6190                </div>
6191                <div class="preset-inline-row">
6192                  <div class="toggle-card" style="margin:0;">
6193                    <div class="field-help-title">Minified files</div>
6194                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
6195                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
6196                  </div>
6197                  <div class="explainer-card prominent" style="margin:0;">
6198                    <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>
6199                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
6200# Heuristic: very long lines + low whitespace ratio
6201#   jquery.min.js  bundle.min.css  → skipped</div>
6202                  </div>
6203                </div>
6204                <div class="preset-inline-row">
6205                  <div class="toggle-card" style="margin:0;">
6206                    <div class="field-help-title">Vendor directories</div>
6207                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
6208                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
6209                  </div>
6210                  <div class="explainer-card prominent" style="margin:0;">
6211                    <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>
6212                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
6213# Directories named vendor/ node_modules/ third_party/
6214#   → entire subtree is excluded from totals</div>
6215                  </div>
6216                </div>
6217                <div class="preset-inline-row">
6218                  <div class="toggle-card" style="margin:0;">
6219                    <div class="field-help-title">Lockfiles and manifests</div>
6220                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
6221                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
6222                  </div>
6223                  <div class="explainer-card prominent" style="margin:0;">
6224                    <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>
6225                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
6226# Files like package-lock.json  Cargo.lock  yarn.lock
6227#   → skipped unless this is enabled</div>
6228                  </div>
6229                </div>
6230                <div class="preset-inline-row">
6231                  <div class="toggle-card" style="margin:0;">
6232                    <div class="field-help-title">Binary handling</div>
6233                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
6234                    <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>
6235                  </div>
6236                  <div class="explainer-card prominent" style="margin:0;">
6237                    <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>
6238                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
6239# Detected via long lines + low whitespace heuristic
6240#   .png  .exe  .so  → skipped silently</div>
6241                  </div>
6242                </div>
6243                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
6244                  <div class="toggle-card" style="margin:0;">
6245                    <div class="field-help-title">Python docstrings</div>
6246                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
6247                    <label class="checkbox">
6248                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
6249                      <span>Count as comment-style lines</span>
6250                    </label>
6251                  </div>
6252                  <div class="explainer-card prominent" style="margin:0;">
6253                    <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>
6254                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
6255                  </div>
6256                </div>
6257              </div>
6258              <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
6259                  <div class="always-tracked-tip">
6260                    <div class="always-tracked-tip-icon">ℹ</div>
6261                    <div class="always-tracked-tip-body">
6262                      <div class="field-help-title">Always tracked — not configurable</div>
6263                      <h4>Comment and blank-line basics</h4>
6264                      <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 mixed-line policy above only affects lines where executable code and comment text share the same line.</div>
6265                    </div>
6266                  </div>
6267                  <div class="always-tracked-tip">
6268                    <div class="always-tracked-tip-icon">→</div>
6269                    <div class="always-tracked-tip-body">
6270                      <div class="field-help-title">What these settings change</div>
6271                      <h4>Lines on the boundary</h4>
6272                      <div class="advanced-rule-description">The rules on this page only affect lines that live on the boundary between code and comments. A line like <code style="font-size:12px;">x = 1  # counter</code> is the boundary case — it contains both executable code and inline comment text. Every other category is always counted the same regardless of these settings.</div>
6273                    </div>
6274                  </div>
6275                </div>
6276
6277              <div class="wizard-actions">
6278                <div class="left">
6279                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
6280                </div>
6281                <div class="right">
6282                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
6283                </div>
6284              </div>
6285            </div>
6286
6287            <div class="wizard-step" data-step="3">
6288              <div class="section">
6289                <div class="section-kicker">Step 3</div>
6290                <h2>Output and report identity</h2>
6291                <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>
6292                <div class="preset-inline-row" style="align-items:start;">
6293                  <div class="toggle-card" style="margin:0;">
6294                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
6295                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
6296                    <select id="scan_preset">
6297                      <option value="balanced">Balanced local scan</option>
6298                      <option value="code_focused">Code focused</option>
6299                      <option value="comment_audit">Comment audit</option>
6300                      <option value="deep_review">Deep review</option>
6301                    </select>
6302                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
6303                  </div>
6304                  <div class="explainer-card">
6305                    <div class="field-help-title">Selected scan preset</div>
6306                    <div class="explainer-body" id="scan-preset-description"></div>
6307                    <div class="preset-summary-row" id="scan-preset-summary"></div>
6308                    <div class="code-sample" id="scan-preset-example"></div>
6309                    <div class="preset-note" id="scan-preset-note"></div>
6310                  </div>
6311                </div>
6312                <hr class="step3-separator" />
6313                <div class="preset-inline-row" style="align-items:start;">
6314                  <div class="toggle-card" style="margin:0;">
6315                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
6316                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
6317                    <select id="artifact_preset">
6318                      <option value="review">Review bundle</option>
6319                      <option value="full">Full bundle</option>
6320                      <option value="html_only">HTML only</option>
6321                      <option value="machine">Machine bundle</option>
6322                    </select>
6323                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
6324                  </div>
6325                  <div class="explainer-card">
6326                    <div class="field-help-title">Selected artifact preset</div>
6327                    <div class="explainer-body" id="artifact-preset-description"></div>
6328                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
6329                    <div class="code-sample" id="artifact-preset-example"></div>
6330                  </div>
6331                </div>
6332              </div>
6333
6334              <div class="section section-spacer-top">
6335                <div class="output-field-row">
6336                  <div class="field">
6337                    <label for="output_dir">Output directory</label>
6338                    <div class="input-group compact">
6339                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
6340                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
6341                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
6342                    </div>
6343                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
6344                  </div>
6345                  <div class="output-field-aside">
6346                    <strong>Where reports land</strong>
6347                    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.
6348                  </div>
6349                </div>
6350              </div>
6351
6352              <div class="section section-spacer-top">
6353                <div class="output-field-row">
6354                  <div class="field">
6355                    <label for="report_title">Report title</label>
6356                    <input id="report_title" name="report_title" type="text" value="tmp-sloc" placeholder="Project report title" />
6357                    <div class="hint">Appears in HTML and PDF output headers.</div>
6358                  </div>
6359                  <div class="output-field-aside">
6360                    <strong>Shown in exported artifacts</strong>
6361                    This title is embedded in the HTML and PDF reports and stays visible in the workbench header while you configure the run. It defaults to the last folder name of the selected project path.
6362                  </div>
6363                </div>
6364              </div>
6365
6366              <div class="section">
6367                <div class="section-kicker">Artifacts</div>
6368                <div class="artifact-grid">
6369                  <div class="artifact-card selected" data-artifact="html">
6370                    <div class="marker">✓</div>
6371                    <div class="artifact-icon">H</div>
6372                    <h4>HTML report</h4>
6373                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
6374                    <div class="artifact-tags">
6375                      <span class="soft-chip">Best for visual review</span>
6376                      <span class="soft-chip">Embeddable preview</span>
6377                    </div>
6378                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
6379                  </div>
6380                  <div class="artifact-card selected" data-artifact="pdf">
6381                    <div class="marker">✓</div>
6382                    <div class="artifact-icon">P</div>
6383                    <h4>PDF export</h4>
6384                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
6385                    <div class="artifact-tags">
6386                      <span class="soft-chip">Portable snapshot</span>
6387                      <span class="soft-chip">Good for handoff</span>
6388                    </div>
6389                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
6390                  </div>
6391                  <div class="artifact-card selected" data-artifact="json" style="opacity:0.75;pointer-events:none;">
6392                    <div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
6393                    <div class="artifact-icon">J</div>
6394                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">Always on</span></h4>
6395                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
6396                    <div class="artifact-tags">
6397                      <span class="soft-chip">Required for compare</span>
6398                      <span class="soft-chip">Auto-enabled</span>
6399                    </div>
6400                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
6401                  </div>
6402                </div>
6403                <div class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</div>
6404              </div>
6405
6406              <div class="wizard-actions">
6407                <div class="left">
6408                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
6409                </div>
6410                <div class="right">
6411                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
6412                </div>
6413              </div>
6414            </div>
6415
6416            <div class="wizard-step" data-step="4">
6417              <div class="section">
6418                <div class="section-kicker">Step 4</div>
6419                <h2>Review selections and run</h2>
6420                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
6421                <div class="review-grid">
6422                  <div class="review-card highlight">
6423                    <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>
6424                    <ul id="review-scan-summary"></ul>
6425                  </div>
6426                  <div class="review-card highlight">
6427                    <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>
6428                    <ul id="review-count-summary"></ul>
6429                  </div>
6430                  <div class="review-card">
6431                    <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>
6432                    <ul id="review-artifact-summary"></ul>
6433                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
6434                  </div>
6435                  <div class="review-card">
6436                    <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>
6437                    <ul id="review-preview-summary"></ul>
6438                  </div>
6439                </div>
6440              </div>
6441
6442              <div class="wizard-actions">
6443                <div class="left">
6444                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
6445                </div>
6446                <div class="right">
6447                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
6448                </div>
6449              </div>
6450            </div></form>
6451        </div>
6452      </section>
6453    </div>
6454  </div>
6455
6456  <script nonce="{{ csp_nonce }}">
6457    (function () {
6458      function startScanPhase() {
6459        var phaseEl = document.getElementById("scan-phase");
6460        if (!phaseEl) return;
6461        var phases = [
6462          "Discovering files...",
6463          "Decoding file encodings...",
6464          "Detecting languages...",
6465          "Analyzing source lines...",
6466          "Applying counting policies...",
6467          "Aggregating results...",
6468          "Rendering report..."
6469        ];
6470        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
6471        var i = 0;
6472        function next() {
6473          phaseEl.style.opacity = "0";
6474          setTimeout(function () {
6475            phaseEl.textContent = phases[i];
6476            phaseEl.style.opacity = "0.85";
6477            var delay = durations[i] || 1800;
6478            i++;
6479            if (i < phases.length) { setTimeout(next, delay); }
6480          }, 200);
6481        }
6482        next();
6483      }
6484
6485      var form = document.getElementById("analyze-form");
6486      var loading = document.getElementById("loading");
6487      var submitButton = document.getElementById("submit-button");
6488      var pathInput = document.getElementById("path");
6489      var GIT_MODE = !!(pathInput && pathInput.readOnly);
6490      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
6491      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
6492      var outputDirInput = document.getElementById("output_dir");
6493      var reportTitleInput = document.getElementById("report_title");
6494      var previewPanel = document.getElementById("preview-panel");
6495      var refreshButton = document.getElementById("refresh-preview");
6496      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
6497      var useSamplePath = document.getElementById("use-sample-path");
6498      var useDefaultOutput = document.getElementById("use-default-output");
6499      var browsePath = document.getElementById("browse-path");
6500      var browseOutputDir = document.getElementById("browse-output-dir");
6501      var themeToggle = document.getElementById("theme-toggle");
6502      var mixedLinePolicy = document.getElementById("mixed_line_policy");
6503      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
6504      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
6505      var scanPreset = document.getElementById("scan_preset");
6506      var artifactPreset = document.getElementById("artifact_preset");
6507      var includeGlobsInput = document.getElementById("include_globs");
6508      var excludeGlobsInput = document.getElementById("exclude_globs");
6509      var liveReportTitle = document.getElementById("live-report-title");
6510      var navProjectPill = document.getElementById("nav-project-pill");
6511      var navProjectTitle = document.getElementById("nav-project-title");
6512      var reportTitlePreview = null;
6513      var wizardProgressFill = document.getElementById("wizard-progress-fill");
6514      var wizardProgressValue = document.getElementById("wizard-progress-value");
6515      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
6516      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
6517      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
6518      var reportTitleTouched = false;
6519      var currentStep = 1;
6520      var previewTimer = null;
6521      var quickScanBtn = document.getElementById("quick-scan-btn");
6522
6523      function dismissAnalysisModal() {
6524        if (loading) loading.classList.remove("active");
6525        ["lc-err","lc-warn","lc-actions"].forEach(function(id) {
6526          var el = document.getElementById(id);
6527          if (el) el.classList.add("hidden");
6528        });
6529        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
6530        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
6531        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
6532        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
6533      }
6534
6535      var lcDismissBtn = document.getElementById("lc-dismiss");
6536      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
6537
6538      function startAsyncAnalysis(formData) {
6539        var gitRepo = (formData.get("git_repo") || "").toString();
6540        var gitRef  = (formData.get("git_ref")  || "").toString();
6541        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
6542        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
6543
6544        var pathEl = document.getElementById("lc-path");
6545        if (pathEl) pathEl.textContent = displayPath;
6546
6547        ["lc-err","lc-warn","lc-actions"].forEach(function(id) {
6548          var el = document.getElementById(id);
6549          if (el) el.classList.add("hidden");
6550        });
6551        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
6552        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
6553
6554        if (loading) loading.classList.add("active");
6555
6556        var startTime = Date.now();
6557        var elapsedTimer = setInterval(function() {
6558          var s = Math.floor((Date.now() - startTime) / 1000);
6559          var el = document.getElementById("lc-elapsed");
6560          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
6561        }, 1000);
6562
6563        var warnShown = false, pollRetries = 0;
6564
6565        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
6566
6567        function lcShowError(msg) {
6568          clearInterval(elapsedTimer);
6569          lcSetPhase("Failed");
6570          var msgEl = document.getElementById("lc-err-msg");
6571          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
6572          var errEl = document.getElementById("lc-err");
6573          var actEl = document.getElementById("lc-actions");
6574          if (errEl) errEl.classList.remove("hidden");
6575          if (actEl) actEl.classList.remove("hidden");
6576          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
6577          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
6578        }
6579
6580        function lcPoll(waitId) {
6581          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
6582            .then(function(r) {
6583              if (!r.ok) throw new Error("HTTP " + r.status);
6584              return r.json();
6585            })
6586            .then(function(data) {
6587              pollRetries = 0;
6588              if (data.state === "complete") {
6589                clearInterval(elapsedTimer);
6590                lcSetPhase("Done");
6591                window.location.href = "/runs/" + encodeURIComponent(data.run_id) + "/result";
6592              } else if (data.state === "failed") {
6593                lcShowError(data.message);
6594              } else {
6595                var s = Math.floor((Date.now() - startTime) / 1000);
6596                if (s > 90 && !warnShown) {
6597                  warnShown = true;
6598                  var w = document.getElementById("lc-warn");
6599                  if (w) w.classList.remove("hidden");
6600                }
6601                lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
6602                setTimeout(function() { lcPoll(waitId); }, 1500);
6603              }
6604            })
6605            .catch(function() {
6606              pollRetries++;
6607              if (pollRetries >= 5) {
6608                lcShowError("Lost connection to server. Reload to check status.");
6609              } else {
6610                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
6611              }
6612            });
6613        }
6614
6615        var params = new URLSearchParams(formData);
6616        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
6617          .then(function(r) {
6618            var waitId = r.headers.get("x-wait-id");
6619            if (!waitId) { window.location.href = "/scan"; return; }
6620            setTimeout(function() { lcPoll(waitId); }, 1500);
6621          })
6622          .catch(function(err) {
6623            lcShowError("Could not reach server: " + (err.message || err));
6624          });
6625      }
6626
6627      if (quickScanBtn) {
6628        quickScanBtn.addEventListener("click", function () {
6629          var pathVal = pathInput ? pathInput.value.trim() : "";
6630          if (!pathVal) {
6631            alert("Please enter or browse to a project path first.");
6632            return;
6633          }
6634          quickScanBtn.disabled = true;
6635          quickScanBtn.textContent = "Scanning...";
6636          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
6637          startAsyncAnalysis(new FormData(form));
6638        });
6639      }
6640
6641      var mixedPolicyInfo = {
6642        code_only: {
6643          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.",
6644          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'
6645        },
6646        code_and_comment: {
6647          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.",
6648          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'
6649        },
6650        comment_only: {
6651          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.",
6652          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'
6653        },
6654        separate_mixed_category: {
6655          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.",
6656          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'
6657        }
6658      };
6659
6660      var scanPresetInfo = {
6661        balanced: {
6662          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.",
6663          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
6664          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
6665          note: "Best when you want a stable local overview before making deeper adjustments.",
6666          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
6667        },
6668        code_focused: {
6669          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
6670          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
6671          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
6672          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
6673          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
6674        },
6675        comment_audit: {
6676          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
6677          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
6678          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
6679          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
6680          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
6681        },
6682        deep_review: {
6683          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
6684          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
6685          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
6686          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
6687          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
6688        }
6689      };
6690
6691      var artifactPresetInfo = {
6692        review: {
6693          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.",
6694          chips: ["HTML", "PDF"],
6695          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
6696        },
6697        full: {
6698          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.",
6699          chips: ["HTML", "PDF", "JSON"],
6700          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
6701        },
6702        html_only: {
6703          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.",
6704          chips: ["HTML only", "Fast local review"],
6705          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
6706        },
6707        machine: {
6708          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
6709          chips: ["HTML", "JSON"],
6710          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
6711        }
6712      };
6713
6714      function applyTheme(theme) {
6715        if (theme === "dark") document.body.classList.add("dark-theme");
6716        else document.body.classList.remove("dark-theme");
6717      }
6718
6719      function loadSavedTheme() {
6720        var saved = null;
6721        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
6722        applyTheme(saved === "dark" ? "dark" : "light");
6723      }
6724
6725      function updateScrollProgress() {
6726        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
6727        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
6728        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
6729        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
6730        var step = Math.min(Math.max(currentStep, 1), 4);
6731        var base = stepBase[step];
6732        var end  = stepEnd[step];
6733
6734        var scrollFrac = 0;
6735        var activePanel = document.querySelector(".wizard-step.active");
6736        if (activePanel) {
6737          var scrollTop = window.scrollY || window.pageYOffset || 0;
6738          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
6739          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
6740          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
6741          var scrolled = scrollTop + viewH - panelTop;
6742          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
6743        }
6744
6745        var percent = Math.round(base + (end - base) * scrollFrac);
6746        percent = Math.min(end, Math.max(base, percent));
6747        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
6748        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
6749      }
6750
6751      function updateWizardProgress() {
6752        updateScrollProgress();
6753      }
6754
6755      var stepDescriptions = [
6756        "Choose a project folder, apply scope filters, and preview which files will be counted.",
6757        "Configure how mixed code-plus-comment lines and docstrings are classified.",
6758        "Pick your output formats, scan preset, and where reports are saved.",
6759        "Review all settings and launch the analysis."
6760      ];
6761
6762      function updateStepNav(step) {
6763        var infoLabel = document.getElementById("step-nav-info-label");
6764        var infoDesc  = document.getElementById("step-nav-info-desc");
6765        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
6766        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
6767
6768      }
6769
6770      function setStep(step, pushHistory) {
6771        currentStep = step;
6772        stepPanels.forEach(function (panel) {
6773          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
6774        });
6775        stepButtons.forEach(function (button) {
6776          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
6777        });
6778        var layoutEl = document.querySelector(".layout");
6779        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
6780        updateWizardProgress();
6781        updateStepNav(step);
6782
6783        if (pushHistory !== false) {
6784          try {
6785            history.pushState({ wizardStep: step }, "", "#step" + step);
6786          } catch (e) {}
6787        }
6788
6789        window.scrollTo({ top: 0, behavior: "instant" });
6790      }
6791
6792      window.addEventListener("popstate", function (e) {
6793        if (e.state && e.state.wizardStep) {
6794          setStep(e.state.wizardStep, false);
6795        } else {
6796          var hashMatch = location.hash.match(/^#step([1-4])$/);
6797          if (hashMatch) setStep(Number(hashMatch[1]), false);
6798        }
6799      });
6800
6801      function inferTitleFromPath(value) {
6802        if (!value) return "project";
6803        var cleaned = value.replace(/[\/\\]+$/, "");
6804        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
6805        return parts.length ? parts[parts.length - 1] : value;
6806      }
6807
6808      function updateReportTitleFromPath() {
6809        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "tmp-sloc");
6810        if (!reportTitleTouched) {
6811          reportTitleInput.value = inferred;
6812        }
6813        var title = reportTitleInput.value || inferred;
6814        if (liveReportTitle) liveReportTitle.textContent = title;
6815        if (reportTitlePreview) reportTitlePreview.textContent = title;
6816        document.title = "OxideSLOC | " + title;
6817
6818        var projectPath = (pathInput.value || "").trim();
6819        if (navProjectPill && navProjectTitle) {
6820          if (projectPath.length > 0) {
6821            navProjectTitle.textContent = inferred;
6822            navProjectPill.classList.add("visible");
6823          } else {
6824            navProjectTitle.textContent = "";
6825            navProjectPill.classList.remove("visible");
6826          }
6827        }
6828      }
6829
6830      function updateMixedPolicyUI() {
6831        var key = mixedLinePolicy.value || "code_only";
6832        var info = mixedPolicyInfo[key];
6833        document.getElementById("mixed-policy-description").textContent = info.description;
6834        document.getElementById("mixed-policy-example").textContent = info.example;
6835      }
6836
6837      function updatePythonDocstringUI() {
6838        var checked = !!pythonDocstrings.checked;
6839        document.getElementById("python-docstring-example").textContent = checked
6840          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
6841          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
6842        document.getElementById("python-docstring-live-help").textContent = checked
6843          ? "Enabled: docstrings contribute to comment-style totals."
6844          : "Disabled: docstrings are not counted as comment content.";
6845      }
6846
6847      function renderPresetChips(targetId, chips) {
6848        var target = document.getElementById(targetId);
6849        if (!target) return;
6850        target.innerHTML = (chips || []).map(function (chip) {
6851          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
6852        }).join('');
6853      }
6854
6855      function updatePresetDescriptions() {
6856        var scanInfo = scanPresetInfo[scanPreset.value];
6857        var artifactInfo = artifactPresetInfo[artifactPreset.value];
6858        document.getElementById("scan-preset-description").textContent = scanInfo.description;
6859        document.getElementById("scan-preset-example").textContent = scanInfo.example;
6860        document.getElementById("scan-preset-note").textContent = scanInfo.note;
6861        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
6862        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
6863        renderPresetChips("scan-preset-summary", scanInfo.chips);
6864        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
6865      }
6866
6867      function applyScanPreset() {
6868        var info = scanPresetInfo[scanPreset.value];
6869        if (!info || !info.apply) return;
6870        mixedLinePolicy.value = info.apply.mixed;
6871        pythonDocstrings.checked = !!info.apply.docstrings;
6872        document.getElementById("generated_file_detection").value = info.apply.generated;
6873        document.getElementById("minified_file_detection").value = info.apply.minified;
6874        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
6875        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
6876        document.getElementById("binary_file_behavior").value = info.apply.binary;
6877        updateMixedPolicyUI();
6878        updatePythonDocstringUI();
6879      }
6880
6881      function applyArtifactPreset() {
6882        var enabled = { html: false, pdf: false, json: false };
6883        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
6884        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
6885        if (artifactPreset.value === "html_only") { enabled.html = true; }
6886        if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
6887
6888        artifactCards.forEach(function (card) {
6889          var artifact = card.getAttribute("data-artifact");
6890          var checked = !!enabled[artifact];
6891          var checkbox = card.querySelector(".artifact-checkbox");
6892          checkbox.checked = checked;
6893          card.classList.toggle("selected", checked);
6894        });
6895      }
6896
6897      function toggleArtifactCard(card) {
6898        var checkbox = card.querySelector(".artifact-checkbox");
6899        checkbox.checked = !checkbox.checked;
6900        card.classList.toggle("selected", checkbox.checked);
6901      }
6902
6903      function updateReview() {
6904        var scanSummary = document.getElementById("review-scan-summary");
6905        var countSummary = document.getElementById("review-count-summary");
6906        var artifactSummary = document.getElementById("review-artifact-summary");
6907        var outputSummary = document.getElementById("review-output-summary");
6908        var previewSummary = document.getElementById("review-preview-summary");
6909        var readinessSummary = document.getElementById("review-readiness-summary");
6910        var includeText = document.getElementById("include_globs").value.trim();
6911        var excludeText = document.getElementById("exclude_globs").value.trim();
6912        var sidePathPreview = document.getElementById("side-path-preview");
6913        var sideOutputPreview = document.getElementById("side-output-preview");
6914        var sideTitlePreview = document.getElementById("side-title-preview");
6915
6916        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "tmp-sloc"; }
6917        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
6918        if (sideTitlePreview) {
6919          var rt = document.getElementById("report_title");
6920          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
6921        }
6922
6923        scanSummary.innerHTML = ""
6924          + "<li>Path: " + escapeHtml(pathInput.value || "tmp-sloc") + "</li>"
6925          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
6926          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
6927
6928        countSummary.innerHTML = ""
6929          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
6930          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
6931          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
6932          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
6933          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
6934          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
6935          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
6936          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
6937
6938        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.querySelector("h4").textContent; });
6939        artifactSummary.innerHTML = ""
6940          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
6941          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
6942
6943        outputSummary.innerHTML = ""
6944          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
6945          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value || "tmp-sloc")) + "</li>";
6946
6947        if (previewSummary) {
6948          if (GIT_MODE) {
6949            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>';
6950          } else {
6951          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
6952          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
6953          var statMap = {};
6954          statButtons.forEach(function (button) {
6955            var valueNode = button.querySelector('.scope-stat-value');
6956            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
6957          });
6958          previewSummary.innerHTML = ''
6959            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
6960            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
6961            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
6962            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
6963            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
6964            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
6965
6966          if (readinessSummary) {
6967            var selectedArtifactsCount = selectedArtifacts.length;
6968            readinessSummary.innerHTML = ''
6969              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
6970              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
6971              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
6972              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
6973          }
6974          } // end else (non-GIT_MODE)
6975        }
6976      }
6977
6978      function escapeHtml(value) {
6979        return String(value)
6980          .replace(/&/g, "&amp;")
6981          .replace(/</g, "&lt;")
6982          .replace(/>/g, "&gt;")
6983          .replace(/"/g, "&quot;")
6984          .replace(/'/g, "&#39;");
6985      }
6986
6987      function isPythonVisible() {
6988        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
6989      }
6990
6991      function syncPythonVisibility() {
6992        var html = previewPanel.textContent || "";
6993        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
6994        pythonWraps.forEach(function (node) {
6995          node.classList.toggle("hidden", !hasPython);
6996        });
6997      }
6998
6999      function attachPreviewInteractions() {
7000        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
7001        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
7002        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
7003        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
7004        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
7005        var searchInput = previewPanel.querySelector("#explorer-search");
7006        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
7007        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
7008        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
7009        var activeFilter = "all";
7010        var activeLanguage = "";
7011        var searchTerm = "";
7012        var currentSortKey = null;
7013        var currentSortOrder = "asc";
7014        var childRows = {};
7015
7016        rows.forEach(function (row) {
7017          var parentId = row.getAttribute("data-parent-id") || "";
7018          var rowId = row.getAttribute("data-row-id") || "";
7019          if (!childRows[parentId]) childRows[parentId] = [];
7020          childRows[parentId].push(rowId);
7021        });
7022
7023        function rowById(id) {
7024          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
7025        }
7026
7027        function hasCollapsedAncestor(row) {
7028          var parentId = row.getAttribute("data-parent-id");
7029          while (parentId) {
7030            var parent = rowById(parentId);
7031            if (!parent) break;
7032            if (parent.getAttribute("data-expanded") === "false") return true;
7033            parentId = parent.getAttribute("data-parent-id");
7034          }
7035          return false;
7036        }
7037
7038        function updateToggleGlyph(row) {
7039          var toggle = row.querySelector(".tree-toggle");
7040          if (!toggle) return;
7041          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
7042        }
7043
7044        function rowSortValue(row, key) {
7045          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
7046        }
7047
7048        function updateSortButtons() {
7049          sortButtons.forEach(function (button) {
7050            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
7051            var indicator = button.querySelector(".tree-sort-indicator");
7052            button.classList.toggle("active", isActive);
7053            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
7054            if (indicator) {
7055              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
7056            }
7057          });
7058        }
7059
7060        function sortSiblingRows() {
7061          if (!treeContainer) {
7062            updateSortButtons();
7063            return;
7064          }
7065
7066          var rowMap = {};
7067          var childrenMap = {};
7068          rows.forEach(function (row) {
7069            var rowId = row.getAttribute("data-row-id");
7070            var parentId = row.getAttribute("data-parent-id") || "";
7071            rowMap[rowId] = row;
7072            if (!childrenMap[parentId]) childrenMap[parentId] = [];
7073            childrenMap[parentId].push(rowId);
7074          });
7075
7076          Object.keys(childrenMap).forEach(function (parentId) {
7077            if (!parentId) return;
7078            childrenMap[parentId].sort(function (a, b) {
7079              var rowA = rowMap[a];
7080              var rowB = rowMap[b];
7081              if (!currentSortKey) {
7082                return Number(a) - Number(b);
7083              }
7084              var valueA = rowSortValue(rowA, currentSortKey);
7085              var valueB = rowSortValue(rowB, currentSortKey);
7086              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
7087              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
7088              var fallbackA = rowSortValue(rowA, "name");
7089              var fallbackB = rowSortValue(rowB, "name");
7090              if (fallbackA < fallbackB) return -1;
7091              if (fallbackA > fallbackB) return 1;
7092              return Number(a) - Number(b);
7093            });
7094          });
7095
7096          var orderedIds = [];
7097          function pushChildren(parentId) {
7098            (childrenMap[parentId] || []).forEach(function (childId) {
7099              orderedIds.push(childId);
7100              pushChildren(childId);
7101            });
7102          }
7103
7104          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
7105            orderedIds.push(topId);
7106            pushChildren(topId);
7107          });
7108
7109          orderedIds.forEach(function (id) {
7110            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
7111          });
7112          updateSortButtons();
7113        }
7114
7115        function updateLanguageButtons() {
7116          languageButtons.forEach(function (button) {
7117            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
7118            var isActive = languageValue === activeLanguage;
7119            button.classList.toggle("active", isActive);
7120          });
7121        }
7122
7123        function rowSelfMatches(row) {
7124          var kind = row.getAttribute("data-kind");
7125          var status = row.getAttribute("data-status");
7126          var language = (row.getAttribute("data-language") || "").toLowerCase();
7127          var name = row.getAttribute("data-name-lower") || "";
7128          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
7129          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
7130          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
7131          var passesLanguage = !activeLanguage || language === activeLanguage;
7132          return passesFilter && passesSearch && passesLanguage;
7133        }
7134
7135        function hasMatchingDescendant(rowId) {
7136          return (childRows[rowId] || []).some(function (childId) {
7137            var childRow = rowById(childId);
7138            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
7139          });
7140        }
7141
7142        function rowMatches(row) {
7143          if (rowSelfMatches(row)) return true;
7144          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
7145        }
7146
7147        function resetViewState() {
7148          activeFilter = "all";
7149          activeLanguage = "";
7150          searchTerm = "";
7151          currentSortKey = null;
7152          currentSortOrder = "asc";
7153          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
7154          if (searchInput) searchInput.value = "";
7155          if (filterSelect) filterSelect.value = "all";
7156          updateLanguageButtons();
7157        }
7158
7159        function applyVisibility() {
7160          rows.forEach(function (row) {
7161            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
7162            row.classList.toggle("hidden-by-filter", !visible);
7163            row.style.display = visible ? "grid" : "none";
7164          });
7165          buttons.forEach(function (button) {
7166            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
7167          });
7168          if (filterSelect) filterSelect.value = activeFilter;
7169        }
7170
7171        buttons.forEach(function (button) {
7172          button.addEventListener("click", function () {
7173            var filterValue = button.getAttribute("data-filter") || "all";
7174            if (filterValue === "reset-view") {
7175              resetViewState();
7176              sortSiblingRows();
7177              applyVisibility();
7178              return;
7179            }
7180            activeFilter = filterValue;
7181            applyVisibility();
7182          });
7183        });
7184
7185        rows.forEach(function (row) {
7186          updateToggleGlyph(row);
7187          var toggle = row.querySelector(".tree-toggle");
7188          if (toggle) {
7189            toggle.addEventListener("click", function () {
7190              var expanded = row.getAttribute("data-expanded") !== "false";
7191              row.setAttribute("data-expanded", expanded ? "false" : "true");
7192              updateToggleGlyph(row);
7193              applyVisibility();
7194            });
7195          }
7196        });
7197
7198        actionButtons.forEach(function (button) {
7199          button.addEventListener("click", function () {
7200            var action = button.getAttribute("data-explorer-action");
7201            if (action === "expand-all") {
7202              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
7203            } else if (action === "collapse-all") {
7204              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
7205            } else if (action === "clear-filters") {
7206              resetViewState();
7207            }
7208            sortSiblingRows();
7209            applyVisibility();
7210          });
7211        });
7212
7213        if (filterSelect) {
7214          filterSelect.addEventListener("change", function () {
7215            activeFilter = filterSelect.value || "all";
7216            applyVisibility();
7217          });
7218        }
7219
7220        languageButtons.forEach(function (button) {
7221          button.addEventListener("click", function () {
7222            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
7223            updateLanguageButtons();
7224            applyVisibility();
7225          });
7226        });
7227
7228        sortButtons.forEach(function (button) {
7229          button.addEventListener("click", function () {
7230            var sortKey = button.getAttribute("data-sort-key");
7231            if (currentSortKey === sortKey) {
7232              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
7233            } else {
7234              currentSortKey = sortKey;
7235              currentSortOrder = "asc";
7236            }
7237            sortSiblingRows();
7238            applyVisibility();
7239          });
7240        });
7241
7242        if (searchInput) {
7243          searchInput.addEventListener("input", function () {
7244            searchTerm = searchInput.value.trim().toLowerCase();
7245            applyVisibility();
7246          });
7247        }
7248
7249        updateLanguageButtons();
7250        sortSiblingRows();
7251        applyVisibility();
7252      }
7253
7254      function loadPreview() {
7255        if (!previewPanel || !pathInput) return;
7256        if (GIT_MODE) {
7257          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>';
7258          return;
7259        }
7260        var path = pathInput.value || "tmp-sloc";
7261        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
7262        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
7263        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
7264        var previewUrl = "/preview?path=" + encodeURIComponent(path)
7265          + "&include_globs=" + encodeURIComponent(includeValue)
7266          + "&exclude_globs=" + encodeURIComponent(excludeValue);
7267        fetch(previewUrl)
7268          .then(function (response) { return response.text(); })
7269          .then(function (html) {
7270            previewPanel.innerHTML = html;
7271            attachPreviewInteractions();
7272            syncPythonVisibility();
7273            updateReview();
7274            setTimeout(collapseLanguagePills, 50);
7275          })
7276          .catch(function (err) {
7277            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
7278          });
7279      }
7280
7281      function pickDirectory(targetInput, kind) {
7282        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
7283        if (browseButton) browseButton.disabled = true;
7284
7285        if (previewPanel && targetInput === pathInput) {
7286          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
7287        }
7288
7289        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
7290          .then(function (response) { return response.json(); })
7291          .then(function (data) {
7292            if (data && data.selected_path) {
7293              targetInput.value = data.selected_path;
7294
7295              if (targetInput === pathInput) {
7296                updateReportTitleFromPath();
7297                autoSetOutputDir(data.selected_path);
7298                fetchProjectHistory(data.selected_path);
7299                loadPreview();
7300              }
7301
7302              updateReview();
7303            } else if (targetInput === pathInput) {
7304              // Cancelled — keep existing value and refresh preview with current path
7305              loadPreview();
7306            }
7307          })
7308          .catch(function () {
7309            window.alert("Directory picker request failed.");
7310            if (previewPanel && targetInput === pathInput) {
7311              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
7312            }
7313          })
7314          .finally(function () {
7315            if (browseButton) browseButton.disabled = false;
7316          });
7317      }
7318
7319      if (themeToggle) {
7320        themeToggle.addEventListener("click", function () {
7321          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
7322          applyTheme(nextTheme);
7323          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
7324        });
7325      }
7326
7327      stepButtons.forEach(function (button) {
7328        button.addEventListener("click", function () {
7329          setStep(Number(button.getAttribute("data-step-target")));
7330        });
7331      });
7332
7333      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
7334        button.addEventListener("click", function () {
7335          setStep(Number(button.getAttribute("data-step-target")) || 1);
7336        });
7337      });
7338
7339      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
7340        button.addEventListener("click", function () {
7341          updateReview();
7342          setStep(Number(button.getAttribute("data-next")));
7343        });
7344      });
7345
7346      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
7347        button.addEventListener("click", function () {
7348          setStep(Number(button.getAttribute("data-prev")));
7349        });
7350      });
7351
7352      if (useSamplePath) {
7353        useSamplePath.addEventListener("click", function () {
7354          pathInput.value = "tmp-sloc";
7355          updateReportTitleFromPath();
7356          loadPreview();
7357        });
7358      }
7359
7360      if (useDefaultOutput) {
7361        useDefaultOutput.addEventListener("click", function () {
7362          delete outputDirInput.dataset.userEdited;
7363          autoSetOutputDir(pathInput ? pathInput.value : "");
7364          updateReview();
7365        });
7366      }
7367
7368      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
7369      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
7370
7371      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
7372
7373      // ── Language pill overflow: collapse to "+N more" chip ─────────────
7374      function collapseLanguagePills() {
7375        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
7376        rows.forEach(function(row) {
7377          // Remove any previous overflow chip
7378          var prev = row.querySelector('.lang-overflow-chip');
7379          if (prev) prev.remove();
7380          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
7381          pills.forEach(function(p) { p.style.display = ''; });
7382          if (!pills.length) return;
7383
7384          // Measure after restoring all pills
7385          var containerRight = row.getBoundingClientRect().right;
7386          var hidden = [];
7387          for (var i = pills.length - 1; i >= 1; i--) {
7388            var rect = pills[i].getBoundingClientRect();
7389            if (rect.right > containerRight + 2) {
7390              hidden.unshift(pills[i]);
7391              pills[i].style.display = 'none';
7392            } else {
7393              break;
7394            }
7395          }
7396
7397          if (hidden.length) {
7398            var chip = document.createElement('button');
7399            chip.type = 'button';
7400            chip.className = 'language-pill lang-overflow-chip';
7401            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
7402            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
7403            row.appendChild(chip);
7404          }
7405        });
7406      }
7407
7408      // Run after preview loads (preview panel populates language pills)
7409      var _origLoadPreviewCb = window.__previewLoaded;
7410      document.addEventListener('previewLoaded', collapseLanguagePills);
7411      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
7412      setTimeout(collapseLanguagePills, 400);
7413
7414      // ── Project history & output dir auto-set ──────────────────────────
7415      var wsOutputRoot   = document.getElementById("ws-output-root");
7416      var wsScanCount    = document.getElementById("ws-scan-count");
7417      var wsLastScan     = document.getElementById("ws-last-scan");
7418      var historyBadge   = document.getElementById("path-history-badge");
7419      var historyTimer   = null;
7420
7421      var wsOutputLink = document.getElementById("ws-output-link");
7422      function syncStripOutputRoot() {
7423        var val = outputDirInput ? outputDirInput.value : "";
7424        var display = val || "project/sloc";
7425        if (wsOutputRoot) wsOutputRoot.textContent = display;
7426        if (wsOutputLink) wsOutputLink.dataset.folder = val;
7427      }
7428
7429      function autoSetOutputDir(projectPath) {
7430        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
7431        if (GIT_MODE && GIT_OUTPUT_DIR) {
7432          outputDirInput.value = GIT_OUTPUT_DIR;
7433          syncStripOutputRoot();
7434          updateReview();
7435          return;
7436        }
7437        if (!projectPath || !projectPath.trim()) return;
7438        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
7439        outputDirInput.value = cleaned + "/sloc";
7440        syncStripOutputRoot();
7441        updateReview();
7442      }
7443
7444      var wsBranch = document.getElementById("ws-branch");
7445
7446      function fetchProjectHistory(projectPath) {
7447        if (!projectPath || !projectPath.trim()) {
7448          if (wsScanCount) wsScanCount.textContent = "—";
7449          if (wsLastScan)  wsLastScan.textContent  = "—";
7450          if (wsBranch)    wsBranch.textContent    = "—";
7451          if (historyBadge) historyBadge.style.display = "none";
7452          return;
7453        }
7454        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
7455          .then(function (r) { return r.ok ? r.json() : null; })
7456          .then(function (data) {
7457            if (!data) return;
7458            var countStr = data.scan_count > 0
7459              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
7460              : "never";
7461            var tsStr = data.last_scan_timestamp
7462              ? data.last_scan_timestamp.replace(" UTC","")
7463              : "—";
7464            if (wsScanCount) wsScanCount.textContent = countStr;
7465            if (wsLastScan)  wsLastScan.textContent  = tsStr;
7466            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
7467            if (data.scan_count > 0) {
7468              if (historyBadge) {
7469                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
7470                historyBadge.textContent = data.scan_count + " previous scan" +
7471                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
7472                  "Last: " + (data.last_scan_timestamp || "—") +
7473                  " — " + (data.last_scan_code_lines ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " code lines.";
7474                historyBadge.className = "path-history-badge found";
7475                historyBadge.style.display = "";
7476              }
7477            } else {
7478              if (historyBadge) historyBadge.style.display = "none";
7479            }
7480          })
7481          .catch(function () {});
7482      }
7483
7484      function onPathChange() {
7485        var val = pathInput ? pathInput.value : "";
7486        updateReportTitleFromPath();
7487        autoSetOutputDir(val);
7488        clearTimeout(historyTimer);
7489        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
7490        if (previewTimer) clearTimeout(previewTimer);
7491        previewTimer = setTimeout(loadPreview, 280);
7492      }
7493
7494      if (pathInput) {
7495        pathInput.addEventListener("input", onPathChange);
7496      }
7497
7498      if (outputDirInput) {
7499        outputDirInput.addEventListener("input", function () {
7500          outputDirInput.dataset.userEdited = "1";
7501          syncStripOutputRoot();
7502          updateReview();
7503        });
7504      }
7505
7506      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
7507        if (!node) return;
7508        node.addEventListener("input", function () {
7509          updateReview();
7510          if (previewTimer) clearTimeout(previewTimer);
7511          previewTimer = setTimeout(loadPreview, 280);
7512        });
7513      });
7514
7515      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
7516        var node = document.getElementById(id);
7517        if (node) node.addEventListener("change", updateReview);
7518      });
7519
7520      if (reportTitleInput) {
7521        reportTitleInput.addEventListener("input", function () {
7522          reportTitleTouched = reportTitleInput.value.trim().length > 0;
7523          updateReportTitleFromPath();
7524          updateReview();
7525        });
7526      }
7527
7528      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
7529      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
7530      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); });
7531      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
7532
7533      artifactCards.forEach(function (card) {
7534        card.addEventListener("click", function () {
7535          toggleArtifactCard(card);
7536          updateReview();
7537        });
7538      });
7539
7540      if (form && loading && submitButton) {
7541        form.addEventListener("submit", function (e) {
7542          e.preventDefault();
7543          submitButton.disabled = true;
7544          submitButton.textContent = "Scanning...";
7545          startAsyncAnalysis(new FormData(form));
7546        });
7547      }
7548
7549      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
7550        btn.addEventListener('click', function () {
7551          var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
7552          if (!folder) return;
7553          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
7554        });
7555      });
7556
7557      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
7558      if (wsOutputLink) {
7559        wsOutputLink.addEventListener('click', function () {
7560          var folder = wsOutputLink.dataset.folder || '';
7561          if (!folder) return;
7562          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
7563        });
7564      }
7565
7566      loadSavedTheme();
7567      updateMixedPolicyUI();
7568      updatePythonDocstringUI();
7569      applyScanPreset();
7570      updatePresetDescriptions();
7571      applyArtifactPreset();
7572      updateReview();
7573      updateScrollProgress(); // initialise bar to 0% (step 1)
7574      window.addEventListener("scroll", updateScrollProgress, { passive: true });
7575      onPathChange();         // seed output dir, history badge, and preview from initial path
7576      loadPreview();
7577      updateStepNav(1);
7578
7579      // Restore step from URL hash on initial load (e.g., back-forward cache)
7580      (function() {
7581        var hashMatch = location.hash.match(/^#step([1-4])$/);
7582        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
7583      })();
7584
7585      (function randomizeWatermarks() {
7586        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
7587        if (!wms.length) return;
7588        var placed = [];
7589        function tooClose(top, left) {
7590          for (var i = 0; i < placed.length; i++) {
7591            var dt = Math.abs(placed[i][0] - top);
7592            var dl = Math.abs(placed[i][1] - left);
7593            if (dt < 16 && dl < 12) return true;
7594          }
7595          return false;
7596        }
7597        function pick(leftBand) {
7598          for (var attempt = 0; attempt < 50; attempt++) {
7599            var top = Math.random() * 88 + 2;
7600            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7601            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
7602          }
7603          var top = Math.random() * 88 + 2;
7604          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
7605          placed.push([top, left]);
7606          return [top, left];
7607        }
7608        var half = Math.floor(wms.length / 2);
7609        wms.forEach(function (img, i) {
7610          var pos = pick(i < half);
7611          var size = Math.floor(Math.random() * 80 + 110);
7612          var rot = (Math.random() * 360).toFixed(1);
7613          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
7614          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;
7615        });
7616      })();
7617
7618      (function spawnCodeParticles() {
7619        var container = document.getElementById('code-particles');
7620        if (!container) return;
7621        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'];
7622        for (var i = 0; i < 38; i++) {
7623          (function(idx) {
7624            var el = document.createElement('span');
7625            el.className = 'code-particle';
7626            el.textContent = snippets[idx % snippets.length];
7627            var left = Math.random() * 94 + 2;
7628            var top = Math.random() * 88 + 6;
7629            var dur = (Math.random() * 10 + 9).toFixed(1);
7630            var delay = (Math.random() * 18).toFixed(1);
7631            var rot = (Math.random() * 26 - 13).toFixed(1);
7632            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7633            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';
7634            container.appendChild(el);
7635          })(i);
7636        }
7637      })();
7638    })();
7639  </script>
7640  <script nonce="{{ csp_nonce }}">
7641    (function () {
7642      var raw = {{ prefill_json|safe }};
7643      if (!raw || typeof raw !== 'object' || !raw.path) return;
7644      function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
7645      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
7646      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
7647      setVal('path-input', raw.path || '');
7648      setVal('include-globs', raw.include_globs || '');
7649      setVal('exclude-globs', raw.exclude_globs || '');
7650      setVal('output-dir', raw.output_dir || '');
7651      setVal('report-title', raw.report_title || '');
7652      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
7653      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
7654      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
7655      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
7656      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
7657      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
7658      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
7659      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
7660      setChecked('generate-html', raw.generate_html !== false);
7661      setChecked('generate-pdf', !!raw.generate_pdf);
7662      // Trigger dynamic UI updates after pre-fill.
7663      setTimeout(function () {
7664        var pathEl = document.getElementById('path-input');
7665        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
7666        var policyEl = document.getElementById('mixed-line-policy');
7667        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
7668      }, 80);
7669    })();
7670  </script>
7671  <footer class="site-footer">
7672    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
7673    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7674    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7675    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7676  </footer>
7677</body>
7678</html>
7679"##,
7680    ext = "html"
7681)]
7682struct IndexTemplate {
7683    version: &'static str,
7684    prefill_json: String,
7685    csp_nonce: String,
7686    git_repo: String,
7687    git_ref: String,
7688    git_label_json: String,
7689    git_output_dir_json: String,
7690}
7691
7692// ── SplashTemplate ────────────────────────────────────────────────────────────
7693
7694#[derive(Template)]
7695#[template(
7696    source = r##"
7697<!doctype html>
7698<html lang="en">
7699<head>
7700  <meta charset="utf-8">
7701  <meta name="viewport" content="width=device-width, initial-scale=1">
7702  <title>OxideSLOC — Source Line Analysis Workbench</title>
7703  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7704  <style nonce="{{ csp_nonce }}">
7705    :root {
7706      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
7707      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7708      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7709      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7710      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
7711    }
7712    body.dark-theme {
7713      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
7714      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
7715    }
7716    *{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);}
7717    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7718    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7719    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7720    .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;}
7721    @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));}}
7722    .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);}
7723    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7724    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .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));}
7725    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7726    .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;}
7727    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7728    .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;}
7729    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7730    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
7731    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7732    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7733    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7734    .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;}
7735    .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;}
7736    .page{max-width:1100px;margin:0 auto;padding:48px 24px 16px;position:relative;z-index:1;}
7737    .hero{text-align:center;margin-bottom:52px;}
7738    .hero-logo{width:88px;height:97px;object-fit:contain;margin-bottom:6px;filter:drop-shadow(0 8px 22px rgba(184,93,51,0.30));animation:logoBob 3.6s ease-in-out infinite;}
7739    @keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
7740    .hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
7741      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
7742      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
7743      animation:titleShimmer 4s linear infinite;}
7744    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
7745    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;}
7746    .hero-subtitle{font-size:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
7747    @keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
7748    .action-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:32px;}
7749    @media(max-width:900px){.action-grid{grid-template-columns:1fr 1fr;}}
7750    @media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
7751    .action-card{display:flex;flex-direction:column;align-items:flex-start;padding:28px 26px 24px;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;}
7752    .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;}
7753    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
7754    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
7755    .action-card-icon{width:52px;height:52px;border-radius:16px;display:flex;align-items:center;justify-content:center;margin-bottom:18px;flex:0 0 auto;transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
7756    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
7757    .action-card-icon svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;}
7758    .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);}
7759    .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);}
7760    .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);}
7761    .action-card-title{font-size:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
7762    .action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
7763    .action-card-cta{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:800;color:var(--oxide-2);transition:gap 0.15s ease;}
7764    body.dark-theme .action-card-cta{color:var(--oxide);}
7765    .action-card.view .action-card-cta{color:var(--accent-2);}
7766    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
7767    .action-card.compare .action-card-cta{color:#7c3aed;}
7768    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
7769    .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);}
7770    .action-card.git-tools .action-card-cta{color:#15803d;}
7771    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
7772    .action-card:hover .action-card-cta{gap:12px;}
7773    .divider{height:1px;background:var(--line);margin:40px 0;}
7774    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
7775    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
7776    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
7777    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;text-align:center;position:relative;cursor:default;
7778      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
7779    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
7780    .info-chip-val{font-size:22px;font-weight:900;color:var(--oxide);}
7781    body.dark-theme .info-chip-val{color:var(--oxide);}
7782    .info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7783    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
7784      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
7785      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
7786    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
7787      border:6px solid transparent;border-top-color:var(--text);}
7788    .info-chip:hover .info-chip-tip{display:block;}
7789    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7790    .site-footer a{color:var(--muted);}
7791    .lan-card{border-radius:var(--radius);border:1.5px solid var(--line-strong);background:var(--surface);box-shadow:var(--shadow);padding:24px 28px;margin-bottom:32px;animation:cardRise 0.7s ease both;}
7792    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
7793    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
7794    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
7795    .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;}
7796    .lan-badge.local{background:var(--oxide-2);}
7797    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
7798    .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);}
7799    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
7800    .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;}
7801    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
7802    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
7803    .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;}
7804    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
7805    .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;}
7806    .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);}
7807    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
7808    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
7809    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
7810    .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;}.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;}
7811  </style>
7812</head>
7813<body>
7814  <div class="background-watermarks" aria-hidden="true">
7815    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7816    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7817    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7818    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7819    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7820    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7821    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7822  </div>
7823  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7824  <div class="top-nav">
7825    <div class="top-nav-inner">
7826      <a class="brand" href="/">
7827        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7828        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
7829      </a>
7830      <div class="nav-right">
7831        <a class="nav-pill" href="/">Home</a>
7832        <a class="nav-pill" href="/view-reports">View Reports</a>
7833        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7834        <div class="nav-dropdown">
7835          <button class="nav-dropdown-btn" type="button">Git Tools <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></button>
7836          <div class="nav-dropdown-menu">
7837            <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
7838            <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
7839          </div>
7840        </div>
7841        <div class="server-status-wrap">
7842          {% if server_mode %}
7843          <div class="nav-pill server-online-pill"><span class="status-dot"></span>LAN server</div>
7844          <div class="server-status-tip">OxideSLOC is running in server mode — accessible on your LAN.<br>Use Ctrl+C in the terminal to stop.</div>
7845          {% else %}
7846          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Running locally</div>
7847          <div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
7848          {% endif %}
7849        </div>
7850        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7851          <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>
7852          <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>
7853        </button>
7854      </div>
7855    </div>
7856  </div>
7857
7858  <div class="page">
7859    <div class="hero">
7860      <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
7861      <h1 class="hero-title">OxideSLOC</h1>
7862      <p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
7863    </div>
7864
7865    <div class="action-grid">
7866      <a class="action-card scan" href="/scan-setup">
7867        <div class="action-card-icon">
7868          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
7869        </div>
7870        <div class="action-card-title">Scan Project</div>
7871        <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.</p>
7872        <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>
7873      </a>
7874
7875      <a class="action-card view" href="/view-reports">
7876        <div class="action-card-icon">
7877          <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
7878        </div>
7879        <div class="action-card-title">View Reports</div>
7880        <p class="action-card-desc">Browse previously recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
7881        <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>
7882      </a>
7883
7884      <a class="action-card compare" href="/compare-scans">
7885        <div class="action-card-icon">
7886          <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>
7887        </div>
7888        <div class="action-card-title">Compare Scans</div>
7889        <p class="action-card-desc">Pick any two scan builds to see a side-by-side delta — added, removed, and modified files with exact line-count changes.</p>
7890        <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>
7891      </a>
7892
7893      <a class="action-card git-tools" href="/git-browser">
7894        <div class="action-card-icon">
7895          <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>
7896        </div>
7897        <div class="action-card-title">Git Browser</div>
7898        <p class="action-card-desc">Browse repository branches and commits in the Git Browser, or configure webhook triggers and automated scan schedules.</p>
7899        <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>
7900      </a>
7901    </div>
7902
7903    {% if server_mode %}
7904    <div class="lan-card server">
7905      <div class="lan-card-header">
7906        <span class="lan-badge">LAN server</span>
7907        Accessible on your network
7908      </div>
7909      {% if let Some(ip) = lan_ip %}
7910      <div class="lan-url-row">
7911        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
7912        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
7913          <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>
7914          Copy URL
7915        </button>
7916      </div>
7917      <p class="lan-hint">Share this address with anyone on the same network. They will be asked to authenticate.</p>
7918      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
7919      {% else %}
7920      <p class="lan-hint">Could not auto-detect your LAN IP. Find it with <code>hostname -I</code> (Linux) or <code>ipconfig</code> (Windows), then open <code>http://&lt;your-ip&gt;:{{ port }}</code>.</p>
7921      {% endif %}
7922    </div>
7923    {% endif %}
7924
7925    <div class="divider"></div>
7926
7927    <div class="info-strip">
7928      <div class="info-chip">
7929        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
7930        <div class="info-chip-val">41</div>
7931        <div class="info-chip-label">Languages</div>
7932      </div>
7933      <div class="info-chip">
7934        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
7935        <div class="info-chip-val">100%</div>
7936        <div class="info-chip-label">Self-contained</div>
7937      </div>
7938      <div class="info-chip">
7939        <div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
7940        <div class="info-chip-val">HTML</div>
7941        <div class="info-chip-label">Exportable reports</div>
7942      </div>
7943      <div class="info-chip">
7944        <div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
7945        <div class="info-chip-val">Git</div>
7946        <div class="info-chip-label">Submodule support</div>
7947      </div>
7948      <div class="info-chip">
7949        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
7950        <div class="info-chip-val">IEEE</div>
7951        <div class="info-chip-label">1045-1992</div>
7952      </div>
7953    </div>
7954
7955    {% if lan_ip.is_none() %}
7956    <div class="lan-local-hint">
7957      <strong>Want teammates on the same network to access this?</strong><br>
7958      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
7959    </div>
7960    {% endif %}
7961  </div>
7962
7963  <footer class="site-footer">
7964    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
7965    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7966    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7967    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7968  </footer>
7969
7970  <script nonce="{{ csp_nonce }}">
7971    (function () {
7972      var storageKey = 'oxide-sloc-theme';
7973      var body = document.body;
7974      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
7975      var toggle = document.getElementById('theme-toggle');
7976      if (toggle) toggle.addEventListener('click', function () {
7977        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
7978        body.classList.toggle('dark-theme', next === 'dark');
7979        try { localStorage.setItem(storageKey, next); } catch(e) {}
7980      });
7981      var copyBtn = document.getElementById('lan-copy-btn');
7982      if (copyBtn) copyBtn.addEventListener('click', function() {
7983        var btn = this;
7984        var el = document.getElementById('lan-url-val');
7985        if (!el) return;
7986        var url = el.textContent.trim();
7987        if (navigator.clipboard) {
7988          navigator.clipboard.writeText(url).then(function() {
7989            var orig = btn.innerHTML;
7990            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!';
7991            setTimeout(function() { btn.innerHTML = orig; }, 1800);
7992          });
7993        }
7994      });
7995      (function randomizeWatermarks() {
7996        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7997        if (!wms.length) return;
7998        var placed = [];
7999        function tooClose(top, left) {
8000          for (var i = 0; i < placed.length; i++) {
8001            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
8002            if (dt < 16 && dl < 12) return true;
8003          }
8004          return false;
8005        }
8006        function pick(leftBand) {
8007          for (var attempt = 0; attempt < 50; attempt++) {
8008            var top = Math.random() * 88 + 2;
8009            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8010            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
8011          }
8012          var top = Math.random() * 88 + 2;
8013          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8014          placed.push([top, left]); return [top, left];
8015        }
8016        var half = Math.floor(wms.length / 2);
8017        wms.forEach(function (img, i) {
8018          var pos = pick(i < half);
8019          var size = Math.floor(Math.random() * 100 + 120);
8020          var rot = (Math.random() * 360).toFixed(1);
8021          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
8022          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;
8023        });
8024      })();
8025
8026      (function spawnCodeParticles() {
8027        var container = document.getElementById('code-particles');
8028        if (!container) return;
8029        var snippets = [
8030          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
8031          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
8032          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
8033          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
8034          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
8035        ];
8036        var count = 38;
8037        for (var i = 0; i < count; i++) {
8038          (function(idx) {
8039            var el = document.createElement('span');
8040            el.className = 'code-particle';
8041            var text = snippets[idx % snippets.length];
8042            el.textContent = text;
8043            var left = Math.random() * 94 + 2;
8044            var top = Math.random() * 88 + 6;
8045            var dur = (Math.random() * 10 + 9).toFixed(1);
8046            var delay = (Math.random() * 18).toFixed(1);
8047            var rot = (Math.random() * 26 - 13).toFixed(1);
8048            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8049            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
8050              + '--rot:' + rot + 'deg;--op:' + op + ';'
8051              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8052            container.appendChild(el);
8053          })(i);
8054        }
8055      })();
8056    })();
8057  </script>
8058</body>
8059</html>
8060"##,
8061    ext = "html"
8062)]
8063struct SplashTemplate {
8064    csp_nonce: String,
8065    server_mode: bool,
8066    lan_ip: Option<String>,
8067    port: u16,
8068    version: &'static str,
8069}
8070
8071// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
8072
8073#[derive(Template)]
8074#[template(
8075    source = r##"
8076<!doctype html>
8077<html lang="en">
8078<head>
8079  <meta charset="utf-8">
8080  <meta name="viewport" content="width=device-width, initial-scale=1">
8081  <title>OxideSLOC — Start a Scan</title>
8082  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8083  <style nonce="{{ csp_nonce }}">
8084    :root {
8085      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
8086      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8087      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
8088      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8089      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
8090    }
8091    body.dark-theme {
8092      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
8093      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
8094    }
8095    *{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);}
8096    .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);}
8097    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
8098    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
8099    .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));}
8100    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8101    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
8102    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
8103    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
8104    .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;}
8105    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
8106    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
8107    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8108    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8109    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8110    .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
8111    .page-header{text-align:center;margin-bottom:32px;}
8112    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
8113    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
8114    .breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
8115    .breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
8116    .breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
8117    /* Cards */
8118    .option-grid{display:flex;flex-direction:column;gap:16px;}
8119    .option-card{background:var(--surface);border:1.5px solid var(--line-strong);border-radius:var(--radius);padding:22px 26px;box-shadow:var(--shadow);transition:border-color 0.18s ease,box-shadow 0.18s ease;}
8120    .option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
8121    /* Two-column layout inside each card */
8122    .card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
8123    .card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
8124    .option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
8125    .option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
8126    .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
8127    .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
8128    .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
8129    .card-text{min-width:0;}
8130    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
8131    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
8132    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
8133    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
8134    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
8135    /* Right CTA column */
8136    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
8137    .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 20px;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;}
8138    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
8139    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
8140    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
8141    body.dark-theme .btn-secondary{color:var(--oxide);}
8142    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
8143    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
8144    /* File input overlay — must be full-width so it aligns with other card-right buttons */
8145    .file-input-wrap{position:relative;width:100%;}
8146    .file-input-wrap .btn{width:100%;}
8147    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
8148    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8149    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8150    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8151    .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;}
8152    @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));}}
8153    /* Recent list (card 3 — full-width section below header) */
8154    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
8155    .recent-list{display:flex;flex-direction:column;gap:8px;}
8156    .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;}
8157    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
8158    .recent-item-info{flex:1;min-width:0;}
8159    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
8160    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
8161    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
8162    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
8163    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8164    .site-footer a{color:var(--muted);}
8165    @media(max-width:680px){
8166      .card-body{grid-template-columns:1fr;}
8167      .card-right{flex-direction:row;flex-wrap:wrap;}
8168      .btn{flex:1;}
8169    }
8170    .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;}.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;}
8171  </style>
8172</head>
8173<body>
8174  <div class="background-watermarks" aria-hidden="true">
8175    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8176    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8177    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8178    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8179    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8180    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8181    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8182  </div>
8183  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8184  <div class="top-nav">
8185    <div class="top-nav-inner">
8186      <a class="brand" href="/">
8187        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8188        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
8189      </a>
8190      <div class="nav-right">
8191        <a class="nav-pill" href="/">Home</a>
8192        <a class="nav-pill" href="/view-reports">View Reports</a>
8193        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8194        <div class="nav-dropdown">
8195          <button class="nav-dropdown-btn" type="button">Git Tools <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></button>
8196          <div class="nav-dropdown-menu">
8197            <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
8198            <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
8199          </div>
8200        </div>
8201        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8202          <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>
8203          <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>
8204        </button>
8205      </div>
8206    </div>
8207  </div>
8208
8209  <div class="page">
8210    <div class="breadcrumb">
8211      <a href="/">Home</a>
8212      <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
8213      <span>Scan Setup</span>
8214    </div>
8215
8216    <div class="page-header">
8217      <h1>How would you like to scan?</h1>
8218      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
8219    </div>
8220
8221    <div class="option-grid">
8222
8223      <!-- Option 1: New scan -->
8224      <div class="option-card">
8225        <div class="card-body">
8226          <div class="card-left">
8227            <div class="option-icon new-scan">
8228              <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
8229            </div>
8230            <div class="card-text">
8231              <div class="option-title">Start a new scan</div>
8232              <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>
8233              <ul class="feature-list">
8234                <li>Live project scope preview before you run</li>
8235                <li>4 line-counting modes with interactive examples</li>
8236                <li>HTML, PDF, and JSON output — your choice</li>
8237                <li>IEEE 1045-1992 compliant physical SLOC counting</li>
8238              </ul>
8239            </div>
8240          </div>
8241          <div class="card-right">
8242            <a class="btn btn-primary" href="/scan">
8243              Configure &amp; scan
8244              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
8245            </a>
8246            <p class="card-tip">Full 4-step setup · all options</p>
8247          </div>
8248        </div>
8249      </div>
8250
8251      <!-- Option 2: Load from config file -->
8252      <div class="option-card">
8253        <div class="card-body">
8254          <div class="card-left">
8255            <div class="option-icon load-config">
8256              <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>
8257            </div>
8258            <div class="card-text">
8259              <div class="option-title">Load a saved config</div>
8260              <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>
8261              <ul class="feature-list">
8262                <li>All 15 settings restored from the file</li>
8263                <li>Fully editable — change path or output dir</li>
8264                <li>Works with any scan-config.json</li>
8265              </ul>
8266            </div>
8267          </div>
8268          <div class="card-right">
8269            <div class="file-input-wrap">
8270              <button class="btn btn-secondary" id="load-config-btn" type="button">
8271                <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>
8272                Choose config file
8273              </button>
8274              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
8275            </div>
8276            <p class="card-tip" id="config-file-name">Exported after every scan</p>
8277          </div>
8278        </div>
8279      </div>
8280
8281      <!-- Option 3: Re-scan recent project -->
8282      <div class="option-card" id="recent-card">
8283        <div class="card-body">
8284          <div class="card-left" style="grid-column:1/-1;">
8285            <div class="option-icon rescan">
8286              <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>
8287            </div>
8288            <div class="card-text">
8289              <div class="option-title">Re-scan a recent project</div>
8290              <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>
8291              <ul class="feature-list">
8292                <li>All 15+ settings restored from the saved config</li>
8293                <li>Path and output dir are editable before running</li>
8294                <li>Only scans with a saved config appear here</li>
8295              </ul>
8296            </div>
8297          </div>
8298        </div>
8299        <div class="section-divider"></div>
8300        <div class="recent-list" id="recent-list">
8301          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
8302        </div>
8303      </div>
8304
8305    </div>
8306  </div>
8307
8308  <footer class="site-footer">
8309    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
8310    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8311    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8312    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8313  </footer>
8314
8315  <script nonce="{{ csp_nonce }}">
8316    (function () {
8317      var storageKey = 'oxide-sloc-theme';
8318      var body = document.body;
8319      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8320      var toggle = document.getElementById('theme-toggle');
8321      if (toggle) toggle.addEventListener('click', function () {
8322        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8323        body.classList.toggle('dark-theme', next === 'dark');
8324        try { localStorage.setItem(storageKey, next); } catch(e) {}
8325      });
8326
8327      (function randomizeWatermarks() {
8328        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8329        if (!wms.length) return;
8330        var placed = [];
8331        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; }
8332        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]; }
8333        var half = Math.floor(wms.length / 2);
8334        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; });
8335      })();
8336      (function spawnCodeParticles() {
8337        var container = document.getElementById('code-particles');
8338        if (!container) return;
8339        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'];
8340        var count = 38;
8341        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); }
8342      })();
8343
8344      // Recent scans data injected from server
8345      var recentScans = {{ recent_scans_json|safe }};
8346
8347      function configToParams(cfg) {
8348        var p = new URLSearchParams();
8349        p.set('prefilled', '1');
8350        if (cfg.path) p.set('path', cfg.path);
8351        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
8352        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
8353        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
8354        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
8355        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
8356        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
8357        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
8358        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
8359        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
8360        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
8361        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
8362        if (cfg.report_title) p.set('report_title', cfg.report_title);
8363        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
8364        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
8365        return p;
8366      }
8367
8368      // Build recent scan list (capped at 3 visible entries)
8369      var list = document.getElementById('recent-list');
8370      var noNote = document.getElementById('no-recent-note');
8371      var hasAny = false;
8372      var MAX_RECENT = 3;
8373      if (Array.isArray(recentScans)) {
8374        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
8375        var shown = 0;
8376        validEntries.forEach(function (entry) {
8377          if (shown >= MAX_RECENT) return;
8378          shown++;
8379          hasAny = true;
8380          var item = document.createElement('div');
8381          item.className = 'recent-item';
8382          item.title = 'Restore all settings and open wizard';
8383          item.innerHTML =
8384            '<div class="recent-item-info">' +
8385              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
8386              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
8387            '</div>' +
8388            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
8389          item.addEventListener('click', function () {
8390            var params = configToParams(entry.config);
8391            window.location.href = '/scan?' + params.toString();
8392          });
8393          list.appendChild(item);
8394        });
8395        if (validEntries.length > MAX_RECENT) {
8396          var moreEl = document.createElement('div');
8397          moreEl.className = 'recent-more-link';
8398          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
8399          list.appendChild(moreEl);
8400        }
8401      }
8402      if (hasAny && noNote) noNote.style.display = 'none';
8403
8404      // Config file loader
8405      var fileInput = document.getElementById('config-file-input');
8406      var fileName = document.getElementById('config-file-name');
8407      if (fileInput) {
8408        fileInput.addEventListener('change', function () {
8409          var file = fileInput.files && fileInput.files[0];
8410          if (!file) return;
8411          if (fileName) fileName.textContent = '✓ ' + file.name;
8412          var reader = new FileReader();
8413          reader.onload = function (e) {
8414            try {
8415              var cfg = JSON.parse(e.target.result);
8416              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
8417              var params = configToParams(cfg);
8418              window.location.href = '/scan?' + params.toString();
8419            } catch (err) {
8420              alert('Could not parse config file: ' + err.message);
8421            }
8422          };
8423          reader.readAsText(file);
8424        });
8425      }
8426
8427      function escHtml(s) {
8428        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
8429      }
8430    })();
8431  </script>
8432</body>
8433</html>
8434"##,
8435    ext = "html"
8436)]
8437struct ScanSetupTemplate {
8438    version: &'static str,
8439    recent_scans_json: String,
8440    csp_nonce: String,
8441}
8442
8443#[derive(Template)]
8444#[template(
8445    source = r##"
8446<!doctype html>
8447<html lang="en">
8448<head>
8449  <meta charset="utf-8">
8450  <meta name="viewport" content="width=device-width, initial-scale=1">
8451  <title>OxideSLOC | {{ report_title }} | Report</title>
8452  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8453  <style nonce="{{ csp_nonce }}">
8454    :root {
8455      --radius: 18px;
8456      --bg: #f5efe8;
8457      --surface: rgba(255,255,255,0.82);
8458      --surface-2: #fbf7f2;
8459      --surface-3: #efe6dc;
8460      --line: #e6d0bf;
8461      --line-strong: #dcb89f;
8462      --text: #43342d;
8463      --muted: #7b675b;
8464      --muted-2: #a08777;
8465      --nav: #b85d33;
8466      --nav-2: #7a371b;
8467      --accent: #6f9bff;
8468      --accent-2: #4a78ee;
8469      --oxide: #d37a4c;
8470      --oxide-2: #b35428;
8471      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
8472      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
8473      --success-bg: #e8f5ed;
8474      --success-text: #1a8f47;
8475      --info-bg: #eef3ff;
8476      --info-text: #4467d8;
8477    }
8478
8479    body.dark-theme {
8480      --bg: #1b1511;
8481      --surface: #261c17;
8482      --surface-2: #2d221d;
8483      --surface-3: #372922;
8484      --line: #524238;
8485      --line-strong: #6c5649;
8486      --text: #f5ece6;
8487      --muted: #c7b7aa;
8488      --muted-2: #aa9485;
8489      --nav: #b85d33;
8490      --nav-2: #7a371b;
8491      --accent: #6f9bff;
8492      --accent-2: #4a78ee;
8493      --oxide: #d37a4c;
8494      --oxide-2: #b35428;
8495      --shadow: 0 18px 42px rgba(0,0,0,0.28);
8496      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
8497      --success-bg: #163927;
8498      --success-text: #8fe2a8;
8499      --info-bg: #1c2847;
8500      --info-text: #a9c1ff;
8501    }
8502
8503    * { box-sizing: border-box; }
8504    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); }
8505    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
8506    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
8507    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
8508    .top-nav, .page { position: relative; z-index: 2; }
8509    .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); }
8510    .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 18px; }
8511    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
8512    .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)); }
8513    .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; }
8514    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
8515    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
8516    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
8517    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
8518    .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; }
8519    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
8520    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
8521    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
8522    .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); text-decoration: none; }
8523    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
8524    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
8525    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
8526    .theme-toggle .icon-sun { display:none; }
8527    body.dark-theme .theme-toggle .icon-sun { display:block; }
8528    body.dark-theme .theme-toggle .icon-moon { display:none; }
8529    .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; }
8530    .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;}
8531    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
8532    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
8533    .hero, .panel { padding: 22px; }
8534    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
8535    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
8536    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
8537    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
8538    .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; }
8539    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
8540    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
8541    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
8542    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
8543    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
8544    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
8545    .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 12px; text-align:center; min-width:80px; }
8546    .delta-card-val { font-size:16px; font-weight:800; }
8547    .delta-card-val.pos { color:#1e7e34; }
8548    .delta-card-val.neg { color:var(--neg); }
8549    .delta-card-val.mod { color:#b35428; }
8550    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
8551    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
8552    .compare-ts { font-size:13px; color:var(--muted); }
8553    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
8554    .compare-arrow { color: var(--muted); }
8555    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
8556    .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; }
8557    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
8558    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
8559    .button, .copy-button {
8560      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;
8561    }
8562    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
8563    @keyframes spin { to { transform: rotate(360deg); } }
8564    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
8565    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
8566    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
8567    .path-item strong { display: block; margin-bottom: 6px; }
8568    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
8569    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
8570    .path-subitem { flex: 1; }
8571    .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); }
8572    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); }
8573    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
8574    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
8575    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
8576    th:first-child, td:first-child { width: 28%; }
8577    th { color: var(--muted); font-weight: 700; }
8578    tr:last-child td { border-bottom: none; }
8579    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
8580    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
8581    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
8582    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
8583    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
8584    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
8585    .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; }
8586    .soft-chip.success { background: var(--success-bg); color: var(--success-text); }
8587    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
8588    .muted { color: var(--muted); }
8589    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8590    .site-footer a{color:var(--muted);}
8591    .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; }
8592    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
8593    .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; }
8594    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
8595    /* Submodule panel */
8596    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
8597    /* Metrics tables stack */
8598    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
8599    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
8600    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
8601    .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)); }
8602    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
8603    /* Metrics table */
8604    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
8605    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
8606    .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; }
8607    .metrics-table thead th:not(:first-child) { text-align: right; }
8608    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
8609    .metrics-table tbody tr:last-child td { border-bottom: none; }
8610    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
8611    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
8612    .metrics-table tbody tr:hover td { background: var(--surface-2); }
8613    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
8614    .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; }
8615    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
8616    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
8617    .mt-val-pos { color: var(--pos); font-weight: 700; }
8618    .mt-val-neg { color: var(--neg); font-weight: 700; }
8619    .mt-val-zero { color: var(--muted); }
8620    .mt-val-mod { color: var(--oxide-2); }
8621    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
8622    @media (max-width: 1180px) {
8623      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
8624      .nav-project-slot, .nav-status { justify-content:flex-start; }
8625      .hero-top { flex-direction: column; }
8626    }
8627    .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;}
8628    @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));}}
8629    .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;}.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;}
8630  </style>
8631</head>
8632<body>
8633  <div class="background-watermarks" aria-hidden="true">
8634    <img src="/images/logo/logo-text.png" alt="" />
8635    <img src="/images/logo/logo-text.png" alt="" />
8636    <img src="/images/logo/logo-text.png" alt="" />
8637    <img src="/images/logo/logo-text.png" alt="" />
8638    <img src="/images/logo/logo-text.png" alt="" />
8639    <img src="/images/logo/logo-text.png" alt="" />
8640    <img src="/images/logo/logo-text.png" alt="" />
8641    <img src="/images/logo/logo-text.png" alt="" />
8642    <img src="/images/logo/logo-text.png" alt="" />
8643    <img src="/images/logo/logo-text.png" alt="" />
8644    <img src="/images/logo/logo-text.png" alt="" />
8645    <img src="/images/logo/logo-text.png" alt="" />
8646    <img src="/images/logo/logo-text.png" alt="" />
8647    <img src="/images/logo/logo-text.png" alt="" />
8648  </div>
8649  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8650  <div class="top-nav">
8651    <div class="top-nav-inner">
8652      <a class="brand" href="/">
8653        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
8654        <div class="brand-copy">
8655          <div class="brand-title">OxideSLOC</div>
8656          <div class="brand-subtitle">Local analysis workbench</div>
8657        </div>
8658      </a>
8659      <div class="nav-project-slot">
8660        <div class="nav-project-pill"><span class="nav-project-label">Project</span><span class="nav-project-value">{{ report_title }}</span></div>
8661      </div>
8662      <div class="nav-status">
8663        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
8664        <a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
8665        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
8666        <div class="nav-dropdown">
8667          <button class="nav-dropdown-btn" type="button">Git Tools <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></button>
8668          <div class="nav-dropdown-menu">
8669            <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
8670            <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
8671          </div>
8672        </div>
8673        <div class="server-status-wrap">
8674          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8675          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
8676        </div>
8677        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
8678          <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>
8679          <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>
8680        </button>
8681      </div>
8682    </div>
8683  </div>
8684
8685  <div class="page">
8686    <section class="hero">
8687      <div class="hero-top">
8688        <div>
8689          <div class="soft-chip success">Run finished successfully</div>
8690          <h1 class="hero-title">{{ report_title }}</h1>
8691          <p class="hero-subtitle">Your HTML, PDF, and JSON artifacts are now saved. Use the quick actions below to view, download, or copy the saved paths for sharing outside the local workbench.</p>
8692        </div>
8693        <div class="hero-quick-actions">
8694          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
8695          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
8696          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
8697        </div>
8698      </div>
8699
8700      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
8701      <div class="compare-banner">
8702        <div class="compare-banner-body">
8703          <div class="compare-banner-meta">
8704            <span class="compare-label">Previous scan</span>
8705            <span class="compare-ts">{{ prev_ts }}</span>
8706            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
8707            {% if let Some(prev_code) = prev_run_code_lines %}
8708            <div class="compare-banner-stats" style="margin-top:4px;">
8709              <span>Code before: <strong>{{ prev_code }}</strong></span>
8710              <span class="compare-arrow">→</span>
8711              <span>Code now: <strong>{{ code_lines }}</strong></span>
8712              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
8713              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
8714            </div>
8715            {% endif %}
8716          </div>
8717          {% if delta_lines_added.is_some() %}
8718          <div class="delta-cards-inline">
8719            <div class="delta-card-inline">
8720              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
8721              <div class="delta-card-lbl">lines added</div>
8722            </div>
8723            <div class="delta-card-inline">
8724              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
8725              <div class="delta-card-lbl">lines removed</div>
8726            </div>
8727            <div class="delta-card-inline">
8728              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
8729              <div class="delta-card-lbl">unmodified lines</div>
8730            </div>
8731            <div class="delta-card-inline">
8732              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
8733              <div class="delta-card-lbl">files modified</div>
8734            </div>
8735            <div class="delta-card-inline">
8736              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
8737              <div class="delta-card-lbl">files added</div>
8738            </div>
8739            <div class="delta-card-inline">
8740              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
8741              <div class="delta-card-lbl">files removed</div>
8742            </div>
8743            <div class="delta-card-inline">
8744              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
8745              <div class="delta-card-lbl">files unchanged</div>
8746            </div>
8747          </div>
8748          {% else %}
8749          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
8750            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
8751          </p>
8752          {% endif %}
8753          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
8754        </div>
8755      </div>
8756      {% endif %}{% endif %}
8757
8758      <div class="action-grid">
8759        <div class="action-card">
8760          <h3>HTML report</h3>
8761          <div class="action-buttons">
8762            {% match html_url %}
8763              {% when Some with (url) %}
8764                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
8765              {% when None %}{% endmatch %}
8766            {% match html_download_url %}
8767              {% when Some with (url) %}
8768                <a class="button secondary" href="{{ url }}">Download HTML</a>
8769              {% when None %}{% endmatch %}
8770            {% match html_path %}
8771              {% when Some with (_path) %}{% when None %}{% endmatch %}
8772          </div>
8773          <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
8774        </div>
8775        <div class="action-card">
8776          <h3>PDF report</h3>
8777          <div class="action-buttons">
8778            {% match pdf_url %}
8779              {% when Some with (url) %}
8780                {% if pdf_generating %}
8781                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
8782                    <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>
8783                    Generating PDF…
8784                  </button>
8785                {% else %}
8786                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
8787                {% endif %}
8788              {% when None %}{% endmatch %}
8789            {% match pdf_download_url %}
8790              {% when Some with (url) %}
8791                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
8792              {% when None %}{% endmatch %}
8793            {% match pdf_path %}
8794              {% when Some with (_path) %}{% when None %}{% endmatch %}
8795          </div>
8796          <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
8797        </div>
8798        <div class="action-card">
8799          <h3>JSON result</h3>
8800          <div class="action-buttons">
8801            {% match json_url %}
8802              {% when Some with (url) %}
8803                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
8804              {% when None %}{% endmatch %}
8805            {% match json_download_url %}
8806              {% when Some with (url) %}
8807                <a class="button secondary" href="{{ url }}">Download JSON</a>
8808              {% when None %}{% endmatch %}
8809            {% match json_path %}
8810              {% when Some with (_path) %}
8811                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
8812              {% when None %}
8813                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
8814              {% endmatch %}
8815          </div>
8816        </div>
8817        <div class="action-card">
8818          <h3>Scan config</h3>
8819          <div class="action-buttons">
8820            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
8821            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
8822          </div>
8823          <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
8824        </div>
8825      </div>
8826      {% if !submodule_rows.is_empty() %}
8827      <div class="submodule-panel">
8828        <div class="toolbar-row">
8829          <div>
8830            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
8831            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
8832          </div>
8833          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
8834        </div>
8835        <div style="overflow:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
8836        <table style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:700px;">
8837          <colgroup>
8838            <col style="width:14%"><col style="width:40%">
8839            <col style="width:6%"><col style="width:8%"><col style="width:6%">
8840            <col style="width:8%"><col style="width:6%"><col style="width:12%">
8841          </colgroup>
8842          <thead>
8843            <tr>
8844              <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>
8845              <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;">Path</th>
8846              <th style="padding:9px 10px;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;">Files</th>
8847              <th style="padding:9px 10px;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;">Physical</th>
8848              <th style="padding:9px 10px;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;">Code</th>
8849              <th style="padding:9px 10px;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;">Comments</th>
8850              <th style="padding:9px 10px;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;">Blank</th>
8851              <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:center;white-space:nowrap;">Report</th>
8852            </tr>
8853          </thead>
8854          <tbody>
8855            {% for row in submodule_rows %}
8856            <tr>
8857              <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
8858              <td style="padding:10px 14px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.relative_path }}"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
8859              <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
8860              <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
8861              <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
8862              <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
8863              <td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
8864              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:center;">{% if let Some(url) = row.html_url %}<a class="button" href="{{ url }}" target="_blank" rel="noopener" style="font-size:12px;padding:6px 12px;min-height:0;">View</a>{% else %}<span style="color:var(--muted);font-size:12px;">—</span>{% endif %}</td>
8865            </tr>
8866            {% endfor %}
8867          </tbody>
8868        </table>
8869        </div>
8870      </div>
8871      {% endif %}
8872
8873      <div class="metrics-tables-stack">
8874
8875        <div class="metrics-table-wrap">
8876          <div class="metrics-table-title">Files</div>
8877          <table class="metrics-table">
8878            <thead>
8879              <tr>
8880                <th>Metric</th>
8881                <th>This Run</th>
8882                <th>Previous</th>
8883                <th>Change</th>
8884              </tr>
8885            </thead>
8886            <tbody>
8887              <tr>
8888                <td>Files analyzed</td>
8889                <td class="mt-val-large">{{ files_analyzed }}</td>
8890                <td>{{ prev_fa_str }}</td>
8891                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
8892              </tr>
8893              <tr>
8894                <td>Files skipped</td>
8895                <td>{{ files_skipped }}</td>
8896                <td>{{ prev_fs_str }}</td>
8897                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
8898              </tr>
8899              <tr>
8900                <td>Files modified</td>
8901                <td class="mt-val-na">—</td>
8902                <td class="mt-val-na">—</td>
8903                <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>
8904              </tr>
8905              <tr>
8906                <td>Files unchanged</td>
8907                <td class="mt-val-na">—</td>
8908                <td class="mt-val-na">—</td>
8909                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
8910              </tr>
8911            </tbody>
8912          </table>
8913        </div>
8914
8915        <div class="metrics-table-wrap">
8916          <div class="metrics-table-title">Line Counts</div>
8917          <table class="metrics-table">
8918            <thead>
8919              <tr>
8920                <th>Metric</th>
8921                <th>This Run</th>
8922                <th>Previous</th>
8923                <th>Change</th>
8924              </tr>
8925            </thead>
8926            <tbody>
8927              <tr>
8928                <td>Physical lines</td>
8929                <td class="mt-val-large">{{ physical_lines }}</td>
8930                <td>{{ prev_pl_str }}</td>
8931                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
8932              </tr>
8933              <tr>
8934                <td>Code lines</td>
8935                <td class="mt-val-large">{{ code_lines }}</td>
8936                <td>{{ prev_cl_str }}</td>
8937                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
8938              </tr>
8939              <tr>
8940                <td>Comment lines</td>
8941                <td>{{ comment_lines }}</td>
8942                <td>{{ prev_cml_str }}</td>
8943                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
8944              </tr>
8945              <tr>
8946                <td>Blank lines</td>
8947                <td>{{ blank_lines }}</td>
8948                <td>{{ prev_bl_str }}</td>
8949                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
8950              </tr>
8951              <tr>
8952                <td>Mixed (separate)</td>
8953                <td>{{ mixed_lines }}</td>
8954                <td class="mt-val-na">—</td>
8955                <td class="mt-val-na">—</td>
8956              </tr>
8957            </tbody>
8958          </table>
8959        </div>
8960
8961        <div class="metrics-tables-lower">
8962          <div class="metrics-table-wrap">
8963            <div class="metrics-table-title">Code Structure</div>
8964            <table class="metrics-table">
8965              <thead>
8966                <tr>
8967                  <th>Metric</th>
8968                  <th>This Run</th>
8969                </tr>
8970              </thead>
8971              <tbody>
8972                <tr>
8973                  <td>Functions</td>
8974                  <td>{{ functions }}</td>
8975                </tr>
8976                <tr>
8977                  <td>Classes / Types</td>
8978                  <td>{{ classes }}</td>
8979                </tr>
8980                <tr>
8981                  <td>Variables</td>
8982                  <td>{{ variables }}</td>
8983                </tr>
8984                <tr>
8985                  <td>Imports</td>
8986                  <td>{{ imports }}</td>
8987                </tr>
8988              </tbody>
8989            </table>
8990          </div>
8991
8992          <div class="metrics-table-wrap">
8993            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
8994            <table class="metrics-table">
8995              <thead>
8996                <tr>
8997                  <th>Metric</th>
8998                  <th>Change</th>
8999                </tr>
9000              </thead>
9001              <tbody>
9002                <tr>
9003                  <td>Lines added</td>
9004                  <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>
9005                </tr>
9006                <tr>
9007                  <td>Lines removed</td>
9008                  <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>
9009                </tr>
9010                <tr>
9011                  <td>Lines modified (net)</td>
9012                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
9013                </tr>
9014                <tr>
9015                  <td>Lines unmodified</td>
9016                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
9017                </tr>
9018              </tbody>
9019            </table>
9020          </div>
9021        </div>
9022
9023      </div>
9024
9025      <div class="path-list">
9026        <div class="path-item">
9027          <div class="path-item-label">Project path</div>
9028          <code>{{ project_path }}</code>
9029        </div>
9030        <div class="path-item">
9031          <div class="path-item-label">Git branch</div>
9032          {% if let Some(branch) = git_branch %}
9033          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
9034          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
9035          {% else %}
9036          <code style="color:var(--muted)">—</code>
9037          {% endif %}
9038        </div>
9039        <div class="path-item">
9040          <div class="path-item-label">Output folder</div>
9041          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
9042        </div>
9043        <div class="path-item">
9044          <div class="path-item-label">Run ID</div>
9045          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
9046            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
9047            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
9048          </div>
9049        </div>
9050      </div>
9051    </section>
9052
9053    <section class="panel" style="margin-bottom: 18px;">
9054        <div class="toolbar-row">
9055          <div>
9056            <h2>Language breakdown</h2>
9057            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
9058          </div>
9059        </div>
9060        <div id="result-lang-charts" style="margin:0 0 18px;"></div>
9061        <table>
9062          <thead>
9063            <tr>
9064              <th>Language</th>
9065              <th>Files</th>
9066              <th>Physical</th>
9067              <th>Code</th>
9068              <th>Comments</th>
9069              <th>Blank</th>
9070              <th>Mixed</th>
9071              <th>Functions</th>
9072              <th>Classes</th>
9073              <th>Variables</th>
9074              <th>Imports</th>
9075            </tr>
9076          </thead>
9077          <tbody>
9078            {% for row in language_rows %}
9079            <tr>
9080              <td>{{ row.language }}</td>
9081              <td>{{ row.files }}</td>
9082              <td>{{ row.physical }}</td>
9083              <td>{{ row.code }}</td>
9084              <td>{{ row.comments }}</td>
9085              <td>{{ row.blank }}</td>
9086              <td>{{ row.mixed }}</td>
9087              <td>{{ row.functions }}</td>
9088              <td>{{ row.classes }}</td>
9089              <td>{{ row.variables }}</td>
9090              <td>{{ row.imports }}</td>
9091            </tr>
9092            {% endfor %}
9093          </tbody>
9094        </table>
9095    </section>
9096
9097  </div>
9098
9099  <script nonce="{{ csp_nonce }}">
9100    (function () {
9101      var body = document.body;
9102      var themeToggle = document.getElementById('theme-toggle');
9103      var storageKey = 'oxide-sloc-theme';
9104
9105      function applyTheme(theme) {
9106        body.classList.toggle('dark-theme', theme === 'dark');
9107      }
9108
9109      function loadSavedTheme() {
9110        try {
9111          var saved = localStorage.getItem(storageKey);
9112          if (saved === 'dark' || saved === 'light') {
9113            applyTheme(saved);
9114          }
9115        } catch (e) {}
9116      }
9117
9118      if (themeToggle) {
9119        themeToggle.addEventListener('click', function () {
9120          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
9121          applyTheme(nextTheme);
9122          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
9123        });
9124      }
9125
9126      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
9127        button.addEventListener('click', function () {
9128          var value = button.getAttribute('data-copy-value') || '';
9129          if (!value) return;
9130          if (navigator.clipboard && navigator.clipboard.writeText) {
9131            navigator.clipboard.writeText(value).catch(function () {});
9132          }
9133        });
9134      });
9135
9136      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
9137        btn.addEventListener('click', function () {
9138          var folder = btn.getAttribute('data-folder') || '';
9139          if (!folder) return;
9140          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
9141        });
9142      });
9143
9144      loadSavedTheme();
9145
9146      // ── Language overview charts ───────────────────────────────────────────
9147      (function(){
9148        var D={{ lang_chart_json|safe }};
9149        if(!D||!D.length)return;
9150        var el=document.getElementById('result-lang-charts');
9151        if(!el)return;
9152        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
9153        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
9154        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
9155        function fmt(n){return Number(n).toLocaleString();}
9156        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
9157        function px(n){return Math.round(n);}
9158        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
9159        var cx=120,cy=120,Ro=100,Ri=54,DW=420,DH=Math.max(270,24+D.length*22);
9160        var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;" xmlns="http://www.w3.org/2000/svg">';
9161        if(D.length===1){
9162          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
9163          ds+='<circle cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+COLS[0]+'" stroke-width="'+rsw+'"/>';
9164        } else {
9165          var ang=-Math.PI/2;
9166          D.forEach(function(d,i){
9167            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
9168            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
9169            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
9170            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
9171            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
9172            ds+='<path 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"/>';
9173            ang+=sw;
9174          });
9175        }
9176        ds+='<text x="'+cx+'" y="'+(cy-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="22" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
9177        ds+='<text x="'+cx+'" y="'+(cy+16)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
9178        D.forEach(function(d,i){
9179          var ly=12+i*22;
9180          if(ly+16>DH)return;
9181          ds+='<rect x="'+(cx+Ro+14)+'" y="'+ly+'" width="13" height="13" fill="'+(COLS[i%COLS.length])+'" rx="3"/>';
9182          ds+='<text x="'+(cx+Ro+32)+'" y="'+(ly+11)+'" font-family="'+FONT+'" font-size="12" fill="#43342d">'+esc(d.lang)+'</text>';
9183        });
9184        ds+='</svg>';
9185        var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
9186        var LW=104,BW=280,rHb=30,bH=22,SH=D.length*rHb+36;
9187        var bs='<svg viewBox="0 0 '+(LW+BW+62)+' '+SH+'" width="'+(LW+BW+62)+'" height="'+SH+'" style="display:block;" xmlns="http://www.w3.org/2000/svg">';
9188        D.forEach(function(d,i){
9189          var y=10+i*rHb,x=LW;
9190          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
9191          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>';
9192          if(cW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
9193          if(cmW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
9194          if(blW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
9195          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>';
9196        });
9197        var ly=SH-16;
9198        bs+='<rect x="'+LW+'" y="'+ly+'" width="10" height="10" fill="'+OX+'"/><text x="'+(LW+14)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>';
9199        bs+='<rect x="'+(LW+56)+'" y="'+ly+'" width="10" height="10" fill="'+GN+'"/><text x="'+(LW+70)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>';
9200        bs+='<rect x="'+(LW+158)+'" y="'+ly+'" width="10" height="10" fill="'+GY+'"/><text x="'+(LW+172)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>';
9201        bs+='</svg>';
9202        var lbl='font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;text-align:center;';
9203        el.innerHTML='<div style="overflow-x:auto;text-align:center;padding:6px 0;">'+
9204          '<table style="display:inline-table;border-collapse:separate;border-spacing:56px 0;margin:0 auto;">'+
9205            '<tr>'+
9206              '<td style="vertical-align:top;padding:0;"><p style="'+lbl+'">Code Lines by Language</p>'+ds+'</td>'+
9207              '<td style="vertical-align:top;padding:0;"><p style="'+lbl+'">Line Mix per Language</p>'+bs+'</td>'+
9208            '</tr>'+
9209          '</table>'+
9210        '</div>';
9211      })();
9212
9213      (function randomizeWatermarks() {
9214        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
9215        if (!wms.length) return;
9216        var placed = [];
9217        function tooClose(top, left) {
9218          for (var i = 0; i < placed.length; i++) {
9219            var dt = Math.abs(placed[i][0] - top);
9220            var dl = Math.abs(placed[i][1] - left);
9221            if (dt < 20 && dl < 18) return true;
9222          }
9223          return false;
9224        }
9225        function pick(leftBand) {
9226          for (var attempt = 0; attempt < 50; attempt++) {
9227            var top = Math.random() * 85 + 5;
9228            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
9229            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
9230          }
9231          var top = Math.random() * 85 + 5;
9232          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
9233          placed.push([top, left]);
9234          return [top, left];
9235        }
9236        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
9237        var half = Math.floor(wms.length / 2);
9238        wms.forEach(function (img, i) {
9239          var pos = pick(i < half);
9240          var size = Math.floor(Math.random() * 100 + 160);
9241          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
9242          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
9243          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;
9244        });
9245      })();
9246
9247      (function spawnCodeParticles() {
9248        var container = document.getElementById('code-particles');
9249        if (!container) return;
9250        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'];
9251        for (var i = 0; i < 38; i++) {
9252          (function(idx) {
9253            var el = document.createElement('span');
9254            el.className = 'code-particle';
9255            el.textContent = snippets[idx % snippets.length];
9256            var left = Math.random() * 94 + 2;
9257            var top = Math.random() * 88 + 6;
9258            var dur = (Math.random() * 10 + 9).toFixed(1);
9259            var delay = (Math.random() * 18).toFixed(1);
9260            var rot = (Math.random() * 26 - 13).toFixed(1);
9261            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9262            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';
9263            container.appendChild(el);
9264          })(i);
9265        }
9266      })();
9267
9268      {% if pdf_generating %}
9269      // Poll for PDF readiness and swap the disabled button to a live link once done.
9270      (function() {
9271        var openBtn = document.getElementById('pdf-open-btn');
9272        var dlBtn = document.getElementById('pdf-download-btn');
9273        function checkPdf() {
9274          fetch('/api/runs/{{ run_id }}/pdf-status')
9275            .then(function(r) { return r.json(); })
9276            .then(function(d) {
9277              if (d.ready) {
9278                if (openBtn) {
9279                  var a = document.createElement('a');
9280                  a.className = 'button';
9281                  a.id = 'pdf-open-btn';
9282                  a.href = '/runs/{{ run_id }}/pdf';
9283                  a.target = '_blank';
9284                  a.rel = 'noopener';
9285                  a.textContent = 'Open PDF';
9286                  openBtn.replaceWith(a);
9287                }
9288                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
9289              } else {
9290                setTimeout(checkPdf, 3000);
9291              }
9292            })
9293            .catch(function() { setTimeout(checkPdf, 5000); });
9294        }
9295        setTimeout(checkPdf, 3000);
9296      })();
9297      {% endif %}
9298
9299    })();
9300  </script>
9301  <footer class="site-footer">
9302    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
9303    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9304    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9305    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9306  </footer>
9307</body>
9308</html>
9309"##,
9310    ext = "html"
9311)]
9312struct ResultTemplate {
9313    version: &'static str,
9314    report_title: String,
9315    project_path: String,
9316    output_dir: String,
9317    run_id: String,
9318    files_analyzed: u64,
9319    files_skipped: u64,
9320    physical_lines: u64,
9321    code_lines: u64,
9322    comment_lines: u64,
9323    blank_lines: u64,
9324    mixed_lines: u64,
9325    functions: u64,
9326    classes: u64,
9327    variables: u64,
9328    imports: u64,
9329    html_url: Option<String>,
9330    pdf_url: Option<String>,
9331    json_url: Option<String>,
9332    html_download_url: Option<String>,
9333    pdf_download_url: Option<String>,
9334    json_download_url: Option<String>,
9335    html_path: Option<String>,
9336    pdf_path: Option<String>,
9337    json_path: Option<String>,
9338    language_rows: Vec<LanguageSummaryRow>,
9339    prev_run_id: Option<String>,
9340    prev_run_timestamp: Option<String>,
9341    prev_run_code_lines: Option<u64>,
9342    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
9343    prev_fa_str: String,
9344    prev_fs_str: String,
9345    prev_pl_str: String,
9346    prev_cl_str: String,
9347    prev_cml_str: String,
9348    prev_bl_str: String,
9349    // Signed change column for main metrics
9350    delta_fa_str: String,
9351    delta_fa_class: String,
9352    delta_fs_str: String,
9353    delta_fs_class: String,
9354    delta_pl_str: String,
9355    delta_pl_class: String,
9356    delta_cl_str: String,
9357    delta_cl_class: String,
9358    delta_cml_str: String,
9359    delta_cml_class: String,
9360    delta_bl_str: String,
9361    delta_bl_class: String,
9362    // delta vs previous scan
9363    delta_lines_added: Option<i64>,
9364    delta_lines_removed: Option<i64>,
9365    delta_lines_net_str: String,
9366    delta_lines_net_class: String,
9367    delta_files_added: Option<usize>,
9368    delta_files_removed: Option<usize>,
9369    delta_files_modified: Option<usize>,
9370    delta_files_unchanged: Option<usize>,
9371    delta_unmodified_lines: Option<u64>,
9372    // git context
9373    git_branch: Option<String>,
9374    git_commit: Option<String>,
9375    git_author: Option<String>,
9376    // history
9377    prev_scan_count: usize,
9378    current_scan_number: usize,
9379    // submodule breakdown (empty when not requested)
9380    submodule_rows: Vec<SubmoduleRow>,
9381    scan_config_url: String,
9382    lang_chart_json: String,
9383    pdf_generating: bool,
9384    csp_nonce: String,
9385}
9386
9387#[derive(Template)]
9388#[template(
9389    source = r##"
9390<!doctype html>
9391<html lang="en">
9392<head>
9393  <meta charset="utf-8">
9394  <meta name="viewport" content="width=device-width, initial-scale=1">
9395  <title>OxideSLOC | Analyzing…</title>
9396  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9397  <style nonce="{{ csp_nonce }}">
9398    :root {
9399      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
9400      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9401      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
9402      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9403    }
9404    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
9405    *{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);}
9406    .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);}
9407    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
9408    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
9409    .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));}
9410    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
9411    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
9412    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
9413    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
9414    .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;}
9415    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
9416    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
9417    .page-body{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
9418    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
9419    .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;}
9420    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
9421    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
9422    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
9423    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
9424    .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;}
9425    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
9426    .metric-card{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:140px;}
9427    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
9428    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
9429    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
9430    .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;}
9431    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
9432    .hidden{display:none!important;}
9433    .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;}
9434    .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;}
9435    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
9436    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
9437    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
9438    .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);}
9439    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
9440    .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;}
9441    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
9442    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
9443    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
9444    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
9445    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
9446    .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;}
9447    @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));}}
9448    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
9449    .site-footer a{color:var(--muted);}
9450    .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;}
9451    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
9452    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
9453    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
9454  </style>
9455</head>
9456<body>
9457  <div class="background-watermarks" aria-hidden="true">
9458    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9459    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9460    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9461    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9462    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9463    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9464  </div>
9465  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9466  <nav class="top-nav">
9467    <div class="top-nav-inner">
9468      <a href="/" class="brand">
9469        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
9470        <div class="brand-copy">
9471          <h1 class="brand-title">OxideSLOC</h1>
9472          <div class="brand-subtitle">Source Line Analysis</div>
9473        </div>
9474      </a>
9475      <div class="nav-right">
9476        <a href="/view-reports" class="nav-pill">View Reports</a>
9477        <a href="/scan" class="nav-pill">New Scan</a>
9478        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9479          <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>
9480          <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>
9481        </button>
9482      </div>
9483    </div>
9484  </nav>
9485  <div class="page-body">
9486    <div class="wait-panel">
9487      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
9488      <h2 class="wait-title">Analyzing your project…</h2>
9489      <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
9490      <div class="path-block">{{ project_path }}</div>
9491      <div class="metrics-row">
9492        <div class="metric-card">
9493          <div class="metric-label">Elapsed</div>
9494          <div class="metric-value" id="elapsed">0s</div>
9495        </div>
9496        <div class="metric-card">
9497          <div class="metric-label">Phase</div>
9498          <div class="metric-value" id="phase">Starting</div>
9499        </div>
9500      </div>
9501      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
9502      <div class="warn-slow hidden" id="warn-slow">
9503        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.
9504      </div>
9505      <div class="err-panel hidden" id="err-panel">
9506        <strong>Analysis failed</strong>
9507        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
9508      </div>
9509      <div class="actions hidden" id="actions">
9510        <a href="/scan" class="btn-primary">Try Again</a>
9511        <a href="/view-reports" class="btn-outline">View Reports</a>
9512      </div>
9513    </div>
9514  </div>
9515  <script nonce="{{ csp_nonce }}">
9516    (function() {
9517      var WAIT_ID = {{ wait_id_json|safe }};
9518      var startTime = Date.now();
9519      var pollInterval = 1500;
9520      var retries = 0;
9521      var maxRetries = 5;
9522      var warnShown = false;
9523
9524      function elapsed() {
9525        return Math.floor((Date.now() - startTime) / 1000);
9526      }
9527
9528      function updateElapsed() {
9529        var s = elapsed();
9530        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
9531      }
9532
9533      function setPhase(txt) {
9534        document.getElementById('phase').textContent = txt;
9535      }
9536
9537      var elapsedTimer = setInterval(updateElapsed, 1000);
9538
9539      function poll() {
9540        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
9541          .then(function(r) {
9542            if (!r.ok) throw new Error('HTTP ' + r.status);
9543            return r.json();
9544          })
9545          .then(function(data) {
9546            retries = 0;
9547            if (data.state === 'complete') {
9548              clearInterval(elapsedTimer);
9549              setPhase('Done');
9550              window.location.href = '/runs/' + encodeURIComponent(data.run_id) + '/result';
9551            } else if (data.state === 'failed') {
9552              clearInterval(elapsedTimer);
9553              setPhase('Failed');
9554              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
9555              document.getElementById('err-panel').classList.remove('hidden');
9556              document.getElementById('actions').classList.remove('hidden');
9557            } else {
9558              // still running
9559              var s = elapsed();
9560              if (s > 90 && !warnShown) {
9561                warnShown = true;
9562                document.getElementById('warn-slow').classList.remove('hidden');
9563              }
9564              setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
9565              setTimeout(poll, pollInterval);
9566            }
9567          })
9568          .catch(function(err) {
9569            retries++;
9570            if (retries >= maxRetries) {
9571              clearInterval(elapsedTimer);
9572              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
9573              document.getElementById('err-panel').classList.remove('hidden');
9574              document.getElementById('actions').classList.remove('hidden');
9575            } else {
9576              // exponential back-off capped at 8s
9577              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
9578            }
9579          });
9580      }
9581
9582      setTimeout(poll, pollInterval);
9583    })();
9584  </script>
9585  <footer class="site-footer">
9586    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
9587    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9588    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9589    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9590  </footer>
9591  <script nonce="{{ csp_nonce }}">
9592    (function(){
9593      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
9594      if(s==="dark")b.classList.add("dark-theme");
9595      var tt=document.getElementById("theme-toggle");
9596      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
9597    })();
9598    (function spawnCodeParticles(){
9599      var c=document.getElementById('code-particles');if(!c)return;
9600      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'];
9601      for(var i=0;i<32;i++){(function(idx){
9602        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
9603        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
9604        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
9605        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
9606        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
9607        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
9608        c.appendChild(el);
9609      })(i);}
9610    })();
9611    (function randomizeWatermarks(){
9612      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9613      var placed=[];
9614      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;}
9615      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];}
9616      var half=Math.floor(wms.length/2);
9617      wms.forEach(function(img,i){
9618        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
9619        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
9620        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
9621        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
9622        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
9623        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
9624      });
9625    })();
9626  </script>
9627</body>
9628</html>
9629"##,
9630    ext = "html"
9631)]
9632struct ScanWaitTemplate {
9633    version: &'static str,
9634    wait_id_json: String,
9635    project_path: String,
9636    csp_nonce: String,
9637}
9638
9639#[derive(Template)]
9640#[template(
9641    source = r##"
9642<!doctype html>
9643<html lang="en">
9644<head>
9645  <meta charset="utf-8">
9646  <meta name="viewport" content="width=device-width, initial-scale=1">
9647  <title>OxideSLOC | Error</title>
9648  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9649  <style nonce="{{ csp_nonce }}">
9650    :root {
9651      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
9652      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9653      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
9654      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9655    }
9656    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
9657    *{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);}
9658    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
9659    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
9660    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
9661    .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);}
9662    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
9663    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .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));}
9664    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
9665    .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;}
9666    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
9667    .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;}
9668    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
9669    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
9670    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
9671    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
9672    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
9673    .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
9674    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
9675    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
9676    .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;}
9677    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
9678    .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);}
9679    .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;}
9680    .btn-secondary:hover{background:var(--line);}
9681    .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;}
9682    .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;}
9683    .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;}
9684    @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));}}
9685    .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;}.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;}
9686  </style>
9687</head>
9688<body>
9689  <div class="background-watermarks" aria-hidden="true">
9690    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9691    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9692    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9693    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9694    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9695    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9696  </div>
9697  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9698  <div class="top-nav">
9699    <div class="top-nav-inner">
9700      <a class="brand" href="/">
9701        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
9702        <div class="brand-copy">
9703          <div class="brand-title">OxideSLOC</div>
9704          <div class="brand-subtitle">Local analysis workbench</div>
9705        </div>
9706      </a>
9707      <div class="nav-right">
9708        <a class="nav-pill" href="/">Home</a>
9709        <a class="nav-pill" href="/view-reports">View Reports</a>
9710        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9711        <div class="nav-dropdown">
9712          <button class="nav-dropdown-btn" type="button">Git Tools <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></button>
9713          <div class="nav-dropdown-menu">
9714            <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
9715            <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
9716          </div>
9717        </div>
9718        <div class="server-status-wrap">
9719          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
9720          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
9721        </div>
9722        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9723          <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>
9724          <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>
9725        </button>
9726      </div>
9727    </div>
9728  </div>
9729
9730  <div class="page">
9731    <div class="panel">
9732      <h1>Analysis failed</h1>
9733      <div class="error-box">{{ message }}</div>
9734      <div class="actions">
9735        <a class="btn-primary" href="/scan">Back to setup</a>
9736        {% if let Some(report_url) = last_report_url %}
9737        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
9738        {% endif %}
9739        <a class="btn-secondary" href="/view-reports">View Reports</a>
9740      </div>
9741    </div>
9742  </div>
9743  <script nonce="{{ csp_nonce }}">
9744    (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");});})();
9745    (function spawnCodeParticles() {
9746      var container = document.getElementById('code-particles');
9747      if (!container) return;
9748      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'];
9749      for (var i = 0; i < 38; i++) {
9750        (function(idx) {
9751          var el = document.createElement('span');
9752          el.className = 'code-particle';
9753          el.textContent = snippets[idx % snippets.length];
9754          var left = Math.random() * 94 + 2;
9755          var top = Math.random() * 88 + 6;
9756          var dur = (Math.random() * 10 + 9).toFixed(1);
9757          var delay = (Math.random() * 18).toFixed(1);
9758          var rot = (Math.random() * 26 - 13).toFixed(1);
9759          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9760          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';
9761          container.appendChild(el);
9762        })(i);
9763      }
9764    })();
9765    (function randomizeWatermarks() {
9766      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9767      var placed = [];
9768      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; }
9769      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]; }
9770      var half = Math.floor(wms.length/2);
9771      wms.forEach(function(img, i) {
9772        var pos = pick(i < half);
9773        var w = Math.floor(Math.random()*60+80);
9774        var rot = (Math.random()*40-20).toFixed(1);
9775        var op = (Math.random()*0.08+0.05).toFixed(2);
9776        var animDur = (Math.random()*6+5).toFixed(1);
9777        var animDelay = (Math.random()*10).toFixed(1);
9778        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';
9779      });
9780    })();
9781  </script>
9782</body>
9783</html>
9784"##,
9785    ext = "html"
9786)]
9787struct ErrorTemplate {
9788    message: String,
9789    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
9790    last_report_url: Option<String>,
9791    /// Label for the secondary action button; defaults to "View last report" when None.
9792    last_report_label: Option<String>,
9793    csp_nonce: String,
9794}
9795
9796// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
9797
9798#[derive(Template)]
9799#[template(
9800    source = r##"
9801<!doctype html>
9802<html lang="en">
9803<head>
9804  <meta charset="utf-8">
9805  <meta name="viewport" content="width=device-width, initial-scale=1">
9806  <title>OxideSLOC | View Reports</title>
9807  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9808  <style nonce="{{ csp_nonce }}">
9809    :root {
9810      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
9811      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9812      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
9813      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9814      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
9815    }
9816    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; }
9817    *{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);}
9818    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
9819    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
9820    .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);}
9821    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
9822    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .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));}
9823    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
9824    .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;}
9825    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
9826    .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;}
9827    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
9828    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
9829    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
9830    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
9831    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
9832    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
9833    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
9834    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
9835    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
9836    .panel-meta{font-size:13px;color:var(--muted);}
9837    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
9838    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
9839    .per-page-label{font-size:13px;color:var(--muted);}
9840    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;}
9841    .filter-input{min-width:180px;cursor:text;}
9842    .table-wrap{width:100%;overflow-x:auto;}
9843    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
9844    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;}
9845    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
9846    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
9847    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
9848    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
9849    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
9850    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
9851    tr:last-child td{border-bottom:none;}
9852    tr:hover td{background:var(--surface-2);}
9853    .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);}
9854    .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);}
9855    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
9856    .metric-num{font-weight:700;color:var(--text);}
9857    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
9858    .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;}
9859    .btn:hover{background:var(--line);}
9860    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
9861    .btn.primary:hover{opacity:.9;}
9862    .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;}
9863    .btn-back:hover{background:var(--line);}
9864    .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;}
9865    .export-btn:hover{background:var(--line);}
9866    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
9867    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
9868    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
9869    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
9870    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
9871    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
9872    .pagination-info{font-size:13px;color:var(--muted);}
9873    .pagination-btns{display:flex;gap:6px;}
9874    .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;}
9875    .pg-btn:hover:not(:disabled){background:var(--line);}
9876    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
9877    .pg-btn:disabled{opacity:.35;cursor:default;}
9878    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
9879    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
9880    .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;}
9881    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
9882    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
9883    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
9884    .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);}
9885    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
9886    .stat-chip:hover .stat-chip-tip{opacity:1;}
9887    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
9888    .site-footer a{color:var(--muted);}
9889    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
9890    .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%;}
9891    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
9892    .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;}
9893    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
9894    .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;}
9895    .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;}
9896    .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;}
9897    @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));}}
9898    .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;}.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;}
9899    .vr-toolbar{display:grid;grid-template-columns:1fr auto;gap:6px 20px;margin-bottom:14px;align-items:center;}
9900    .vr-filters{grid-column:1;grid-row:2;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
9901    .vr-hint{grid-column:2;grid-row:1;text-align:right;margin:0;font-size:13px;color:var(--muted);white-space:nowrap;}
9902    .vr-browse{grid-column:2;grid-row:2;justify-self:end;white-space:nowrap;}
9903    .rpt-btn{min-width:58px;justify-content:center;}
9904    .flex-row{display:flex;align-items:center;gap:8px;}
9905    .report-cell{overflow:visible;white-space:normal;}
9906    #history-table col:nth-child(1){width:185px;}
9907    #history-table col:nth-child(2){width:220px;}
9908    #history-table col:nth-child(3){width:100px;}
9909    #history-table col:nth-child(4){width:72px;}
9910    #history-table col:nth-child(5){width:82px;}
9911    #history-table col:nth-child(6){width:82px;}
9912    #history-table col:nth-child(7){width:65px;}
9913    #history-table col:nth-child(8){width:90px;}
9914    #history-table col:nth-child(9){width:85px;}
9915    #history-table col:nth-child(10){width:115px;}
9916    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
9917    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
9918    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
9919    .submod-details summary::-webkit-details-marker{display:none;}
9920.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
9921    .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;}
9922    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
9923    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
9924  </style>
9925</head>
9926<body>
9927  <div class="background-watermarks" aria-hidden="true">
9928    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9929    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9930    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9931    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9932    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9933    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9934  </div>
9935  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9936  <div class="top-nav">
9937    <div class="top-nav-inner">
9938      <a class="brand" href="/">
9939        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9940        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
9941      </a>
9942      <div class="nav-right">
9943        <a class="nav-pill" href="/">Home</a>
9944        <a class="nav-pill" href="/view-reports">View Reports</a>
9945        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9946        <div class="nav-dropdown">
9947          <button class="nav-dropdown-btn" type="button">Git Tools <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></button>
9948          <div class="nav-dropdown-menu">
9949            <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
9950            <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
9951          </div>
9952        </div>
9953        <div class="server-status-wrap">
9954          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
9955          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
9956        </div>
9957        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9958          <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>
9959          <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>
9960        </button>
9961      </div>
9962    </div>
9963  </div>
9964
9965  <div class="page">
9966    {% if linked_count > 0 %}
9967    <div class="toast-success">
9968      <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>
9969      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
9970    </div>
9971    {% endif %}
9972    {% if total_scans > 0 %}
9973    <div class="summary-strip">
9974      <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>
9975      <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>
9976      <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>
9977      <div class="stat-chip"><div class="stat-chip-tip">Files excluded by policy rules (vendor, generated, binary, lockfiles, etc.) in the most recent scan</div><div class="stat-chip-val" id="agg-skipped">—</div><div class="stat-chip-label">Latest files skipped</div></div>
9978    </div>
9979    {% endif %}
9980
9981    <section class="panel">
9982      <div class="panel-header">
9983        <div>
9984          <h1>View Reports</h1>
9985          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
9986        </div>
9987        <div class="flex-row">
9988          <button type="button" class="export-btn" id="export-csv-btn">
9989            <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>
9990            Export CSV
9991          </button>
9992          <button type="button" class="export-btn" id="export-xls-btn">
9993            <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>
9994            Export Excel
9995          </button>
9996          <a class="btn-back" href="/">
9997            <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>
9998            Home
9999          </a>
10000        </div>
10001      </div>
10002
10003      <div class="vr-toolbar">
10004        <div class="vr-filters">
10005          {% if !entries.is_empty() %}
10006          <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
10007          <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
10008          <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
10009          {% endif %}
10010        </div>
10011        <p class="vr-hint">Have reports saved on disk? Select a folder to load them into the list.</p>
10012        <button type="button" class="btn vr-browse" id="browse-report-btn">
10013          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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>
10014          Browse for Reports…
10015        </button>
10016      </div>
10017
10018      {% if entries.is_empty() %}
10019      <div class="empty-state">
10020        <strong>No reports with viewable HTML yet</strong>
10021        Run a new analysis from the <a href="/scan">scan page</a>, or use the browse button above to link an existing report.
10022      </div>
10023      {% else %}
10024      <div class="table-wrap">
10025        <table id="history-table">
10026          <colgroup>
10027            <col><col><col><col><col><col><col><col><col><col>
10028          </colgroup>
10029          <thead>
10030            <tr id="history-thead">
10031              <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>
10032              <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>
10033              <th>Run ID<div class="col-resize-handle"></div></th>
10034              <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>
10035              <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>
10036              <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>
10037              <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>
10038              <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>
10039              <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>
10040              <th>Report<div class="col-resize-handle"></div></th>
10041            </tr>
10042          </thead>
10043          <tbody id="history-tbody">
10044            {% for entry in entries %}
10045            <tr class="history-row" data-run="{{ entry.run_id }}"
10046                data-timestamp="{{ entry.timestamp }}"
10047                data-project="{{ entry.project_label }}"
10048                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
10049                data-skipped="{{ entry.files_skipped }}"
10050                data-comments="{{ entry.comment_lines }}"
10051                data-blank="{{ entry.blank_lines }}"
10052                data-branch="{{ entry.git_branch }}"
10053                data-commit="{{ entry.git_commit }}"
10054                data-html-url="/runs/{{ entry.run_id }}/html">
10055              <td>{{ entry.timestamp }}</td>
10056              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
10057              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
10058              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
10059              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
10060              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
10061              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
10062              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
10063              <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>
10064              <td class="report-cell">
10065                <div class="actions-cell">
10066                  <a class="btn primary rpt-btn" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" title="View HTML report">View</a>
10067                  {% if entry.has_pdf %}<a class="btn primary rpt-btn" href="/runs/{{ entry.run_id }}/pdf" target="_blank" rel="noopener" title="View PDF report">PDF</a>{% endif %}
10068                </div>
10069                {% if !entry.submodule_links.is_empty() %}
10070                <details class="submod-details">
10071                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
10072                  <div class="submod-link-list">
10073                    {% for sub in entry.submodule_links %}
10074                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
10075                    {% endfor %}
10076                  </div>
10077                </details>
10078                {% endif %}
10079              </td>
10080            </tr>
10081            {% endfor %}
10082          </tbody>
10083        </table>
10084      </div>
10085      <div class="pagination">
10086        <span class="pagination-info" id="pagination-info"></span>
10087        <div class="pagination-btns" id="pagination-btns"></div>
10088        <div class="flex-row">
10089          <span class="per-page-label">Show</span>
10090          <select class="per-page" id="per-page-sel">
10091            <option value="10">10 per page</option>
10092            <option value="25" selected>25 per page</option>
10093            <option value="50">50 per page</option>
10094            <option value="100">100 per page</option>
10095          </select>
10096          <span class="per-page-label" id="page-range-label"></span>
10097        </div>
10098      </div>
10099      {% endif %}
10100    </section>
10101  </div>
10102
10103  <footer class="site-footer">
10104    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
10105    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10106    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10107    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10108  </footer>
10109
10110  <script nonce="{{ csp_nonce }}">
10111    (function () {
10112      // ── Theme ──────────────────────────────────────────────────────────────
10113      var storageKey = 'oxide-sloc-theme';
10114      var body = document.body;
10115      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
10116      var toggle = document.getElementById('theme-toggle');
10117      if (toggle) toggle.addEventListener('click', function () {
10118        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
10119        body.classList.toggle('dark-theme', next === 'dark');
10120        try { localStorage.setItem(storageKey, next); } catch(e) {}
10121      });
10122
10123      // ── State ─────────────────────────────────────────────────────────────
10124      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
10125      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
10126      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
10127
10128      // Aggregate stats from first (most recent) row
10129      if (allRows.length) {
10130        var first = allRows[0];
10131        var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
10132        var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
10133        var se = document.getElementById('agg-skipped'); if (se) se.textContent = first.dataset.skipped;
10134      }
10135
10136      // ── Branch filter population ──────────────────────────────────────────
10137      (function() {
10138        var branches = {};
10139        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
10140        var sel = document.getElementById('branch-filter');
10141        if (sel) Object.keys(branches).sort().forEach(function(b) {
10142          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
10143        });
10144      })();
10145
10146      // ── Filter ────────────────────────────────────────────────────────────
10147      function getFilteredRows() {
10148        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
10149        var branch = ((document.getElementById('branch-filter') || {}).value || '');
10150        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
10151          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
10152          if (branch && (r.dataset.branch || '') !== branch) return false;
10153          return true;
10154        });
10155      }
10156
10157      // ── Pagination ────────────────────────────────────────────────────────
10158      function renderPage() {
10159        var filtered = getFilteredRows();
10160        var total = filtered.length;
10161        var totalPages = Math.max(1, Math.ceil(total / perPage));
10162        currentPage = Math.min(currentPage, totalPages);
10163        var start = (currentPage - 1) * perPage;
10164        var end = Math.min(start + perPage, total);
10165        var shown = {};
10166        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
10167        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
10168          r.style.display = shown[r.dataset.run] ? '' : 'none';
10169        });
10170        var rl = document.getElementById('page-range-label');
10171        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
10172        var info = document.getElementById('pagination-info');
10173        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
10174        var btns = document.getElementById('pagination-btns');
10175        if (!btns) return;
10176        btns.innerHTML = '';
10177        function makeBtn(lbl, pg, active, disabled) {
10178          var b = document.createElement('button');
10179          b.className = 'pg-btn' + (active ? ' active' : '');
10180          b.textContent = lbl; b.disabled = disabled;
10181          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
10182          return b;
10183        }
10184        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
10185        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
10186        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
10187        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
10188      }
10189
10190      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
10191      window.applyFilters = function() { currentPage = 1; renderPage(); };
10192
10193      // ── Sorting ───────────────────────────────────────────────────────────
10194      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
10195      function doSort(col, type, order) {
10196        var tbody = document.getElementById('history-tbody');
10197        if (!tbody) return;
10198        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
10199        rows.sort(function(a, b) {
10200          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
10201          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
10202          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
10203          return va < vb ? 1 : va > vb ? -1 : 0;
10204        });
10205        rows.forEach(function(r) { tbody.appendChild(r); });
10206        currentPage = 1; renderPage();
10207      }
10208      sortHeaders.forEach(function(th) {
10209        th.addEventListener('click', function(e) {
10210          if (e.target.classList.contains('col-resize-handle')) return;
10211          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
10212          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
10213          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
10214          th.classList.add('sort-' + sortOrder);
10215          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
10216          doSort(col, type, sortOrder);
10217        });
10218      });
10219
10220      // ── Column resize ─────────────────────────────────────────────────────
10221      (function() {
10222        var table = document.getElementById('history-table');
10223        if (!table) return;
10224        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
10225        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
10226        ths.forEach(function(th, i) {
10227          var handle = th.querySelector('.col-resize-handle');
10228          if (!handle || !cols[i]) return;
10229          var startX, startW;
10230          handle.addEventListener('mousedown', function(e) {
10231            e.stopPropagation(); e.preventDefault();
10232            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
10233            handle.classList.add('dragging');
10234            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
10235            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
10236            document.addEventListener('mousemove', onMove);
10237            document.addEventListener('mouseup', onUp);
10238          });
10239        });
10240      })();
10241
10242      // ── Reset view ────────────────────────────────────────────────────────
10243      window.resetView = function() {
10244        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
10245        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
10246        sortCol = null; sortOrder = 'asc';
10247        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
10248        var tbody = document.getElementById('history-tbody');
10249        if (tbody) {
10250          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
10251          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
10252          rows.forEach(function(r) { tbody.appendChild(r); });
10253        }
10254        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
10255        var table = document.getElementById('history-table');
10256        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
10257        currentPage = 1; renderPage();
10258      };
10259
10260      renderPage();
10261
10262      // ── Export helpers ────────────────────────────────────────────────────
10263      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
10264      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
10265      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);}
10266      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;');}
10267      function slocXlsx(fname,sheet,hdrs,rows){
10268        var enc=new TextEncoder();
10269        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;}
10270        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;}
10271        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
10272        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
10273        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
10274        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;}
10275        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];}
10276        var rx='<row r="1">';
10277        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
10278        rx+='</row>';
10279        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>';});
10280        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10281        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>';
10282        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>';
10283        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>';
10284        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>',
10285          '_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>',
10286          '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>',
10287          '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>',
10288          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
10289        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'];
10290        var zparts=[],zcds=[],zoff=0,znf=0;
10291        order.forEach(function(name){
10292          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10293          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]);
10294          var entry=new Uint8Array(lha.length+nb.length+sz);
10295          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
10296          zparts.push(entry);
10297          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));
10298          var cde=new Uint8Array(cda.length+nb.length);
10299          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
10300          zcds.push(cde);zoff+=entry.length;znf++;
10301        });
10302        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
10303        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]);
10304        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
10305        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
10306        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
10307        zout.set(new Uint8Array(ea),zpos);
10308        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
10309      }
10310
10311      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
10312      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;}
10313      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
10314      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
10315
10316      var csvBtn = document.getElementById('export-csv-btn');
10317      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
10318      var xlsBtn = document.getElementById('export-xls-btn');
10319      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
10320
10321      // ── Remaining CSP-safe event bindings ────────────────────────────────
10322      (function wireEvents() {
10323        var el;
10324        el = document.getElementById('reset-view-btn');
10325        if (el) el.addEventListener('click', window.resetView);
10326        el = document.getElementById('project-filter');
10327        if (el) el.addEventListener('input', window.applyFilters);
10328        el = document.getElementById('branch-filter');
10329        if (el) el.addEventListener('change', window.applyFilters);
10330        el = document.getElementById('per-page-sel');
10331        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
10332        el = document.getElementById('browse-report-btn');
10333        if (el) el.addEventListener('click', function() {
10334          fetch('/pick-directory?kind=reports')
10335            .then(function(r) { return r.json(); })
10336            .then(function(data) {
10337              if (!data.cancelled && data.selected_path) {
10338                var form = document.createElement('form');
10339                form.method = 'POST';
10340                form.action = '/locate-reports-dir';
10341                var input = document.createElement('input');
10342                input.type = 'hidden';
10343                input.name = 'folder_path';
10344                input.value = data.selected_path;
10345                form.appendChild(input);
10346                document.body.appendChild(form);
10347                form.submit();
10348              }
10349            })
10350            .catch(function(e) { alert('Could not open folder picker: ' + e); });
10351        });
10352      })();
10353
10354      (function randomizeWatermarks() {
10355        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10356        if (!wms.length) return;
10357        var placed = [];
10358        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;}
10359        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];}
10360        var half=Math.floor(wms.length/2);
10361        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;});
10362      })();
10363
10364      (function spawnCodeParticles() {
10365        var container = document.getElementById('code-particles');
10366        if (!container) return;
10367        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'];
10368        for (var i = 0; i < 38; i++) {
10369          (function(idx) {
10370            var el = document.createElement('span');
10371            el.className = 'code-particle';
10372            el.textContent = snippets[idx % snippets.length];
10373            var left = Math.random() * 94 + 2;
10374            var top = Math.random() * 88 + 6;
10375            var dur = (Math.random() * 10 + 9).toFixed(1);
10376            var delay = (Math.random() * 18).toFixed(1);
10377            var rot = (Math.random() * 26 - 13).toFixed(1);
10378            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
10379            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';
10380            container.appendChild(el);
10381          })(i);
10382        }
10383      })();
10384    })();
10385  </script>
10386</body>
10387</html>
10388"##,
10389    ext = "html"
10390)]
10391struct HistoryTemplate {
10392    version: &'static str,
10393    entries: Vec<HistoryEntryRow>,
10394    total_scans: usize,
10395    linked_count: usize,
10396    csp_nonce: String,
10397}
10398
10399// ── CompareSelectTemplate ──────────────────────────────────────────────────────
10400
10401#[derive(Template)]
10402#[template(
10403    source = r##"
10404<!doctype html>
10405<html lang="en">
10406<head>
10407  <meta charset="utf-8">
10408  <meta name="viewport" content="width=device-width, initial-scale=1">
10409  <title>OxideSLOC | Compare Scans</title>
10410  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10411  <style nonce="{{ csp_nonce }}">
10412    :root {
10413      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
10414      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
10415      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
10416      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
10417      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
10418    }
10419    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
10420    *{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);}
10421    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
10422    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
10423    .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);}
10424    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
10425    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .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));}
10426    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
10427    .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;}
10428    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
10429    .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;}
10430    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
10431    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
10432    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
10433    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
10434    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
10435    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
10436    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
10437    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
10438    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
10439    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
10440    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
10441    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
10442    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
10443    .per-page-label{font-size:13px;color:var(--muted);}
10444    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;}
10445    .filter-input{min-width:180px;cursor:text;}
10446    .table-wrap{width:100%;overflow-x:auto;}
10447    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
10448    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;}
10449    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--accent-2);}
10450    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
10451    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent-2);}
10452    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
10453    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(111,155,255,0.3);}
10454    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
10455    tr:last-child td{border-bottom:none;}
10456    tr.selected td{background:var(--sel-bg);}
10457    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
10458    tr:hover:not(.selected) td{background:var(--surface-2);}
10459    tr{cursor:pointer;}
10460    .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);}
10461    .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);}
10462    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
10463    .metric-num{font-weight:700;}
10464    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
10465    .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;}
10466    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
10467    #compare-table td:nth-child(3){white-space:normal;word-break:break-word;overflow:visible;}
10468    .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;}
10469    .btn:hover{background:var(--line);}
10470    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
10471    .btn.primary:hover{opacity:.9;}
10472    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
10473    .filter-row{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;}
10474    .filter-row>*{height:30px;box-sizing:border-box;}
10475    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
10476    .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;}
10477    .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;}
10478    .btn-back:hover{background:var(--line);}
10479    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
10480    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
10481    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
10482    .pagination-info{font-size:13px;color:var(--muted);}
10483    .pagination-btns{display:flex;gap:6px;}
10484    .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;}
10485    .pg-btn:hover:not(:disabled){background:var(--line);}
10486    .pg-btn.active{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
10487    .pg-btn:disabled{opacity:.35;cursor:default;}
10488    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
10489    .site-footer a{color:var(--muted);}
10490    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
10491    .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;}
10492    .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;}
10493    .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;}
10494    @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));}}
10495    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
10496    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
10497    .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;}
10498    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
10499    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
10500    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
10501    .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);}
10502    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
10503    .stat-chip:hover .stat-chip-tip{opacity:1;}
10504    .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;}
10505    .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%;}
10506    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
10507    .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;}
10508    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
10509    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
10510    .hidden{display:none!important;}
10511    .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;}
10512    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
10513    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
10514    .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;}
10515    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
10516    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
10517    .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;}
10518    .scope-option:hover{background:var(--line);}
10519    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
10520    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
10521    .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;}
10522    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
10523    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
10524    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
10525    .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;}.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;}
10526  </style>
10527</head>
10528<body>
10529  <div class="background-watermarks" aria-hidden="true">
10530    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10531    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10532    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10533    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10534    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10535    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10536  </div>
10537  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10538  <div class="top-nav">
10539    <div class="top-nav-inner">
10540      <a class="brand" href="/">
10541        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10542        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
10543      </a>
10544      <div class="nav-right">
10545        <a class="nav-pill" href="/">Home</a>
10546        <a class="nav-pill" href="/view-reports">View Reports</a>
10547        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10548        <div class="nav-dropdown">
10549          <button class="nav-dropdown-btn" type="button">Git Tools <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></button>
10550          <div class="nav-dropdown-menu">
10551            <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
10552            <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
10553          </div>
10554        </div>
10555        <div class="server-status-wrap">
10556          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
10557          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
10558        </div>
10559        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10560          <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>
10561          <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>
10562        </button>
10563      </div>
10564    </div>
10565  </div>
10566
10567  <div class="page">
10568    {% if total_scans > 0 %}
10569    <div class="summary-strip">
10570      <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>
10571      <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>
10572      <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>
10573      <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>
10574    </div>
10575    {% endif %}
10576    <section class="panel">
10577      <div class="panel-header">
10578        <div>
10579          <h1>Compare Scans</h1>
10580          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
10581        </div>
10582        <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
10583          <button class="btn primary" id="compare-btn" disabled>
10584            <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>
10585            Compare <span class="sel-count" id="sel-count">0/2</span>
10586          </button>
10587          <a class="btn-back" href="/">
10588            <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>
10589            Home
10590          </a>
10591        </div>
10592      </div>
10593
10594      {% if entries.is_empty() %}
10595      <div class="empty-state">
10596        <strong>No scans yet</strong>
10597        Run your first analysis from the <a href="/scan">scan page</a>.
10598      </div>
10599      {% else %}
10600      <div class="instruction-bar">
10601        <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>
10602        Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
10603      </div>
10604      <div class="filter-row">
10605        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
10606        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
10607        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
10608      </div>
10609      <div class="scope-panel hidden" id="scope-panel">
10610        <div class="scope-panel-label">
10611          <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>
10612          Compare scope — choose what to include
10613        </div>
10614        <div class="scope-options" id="scope-options"></div>
10615      </div>
10616      <div class="table-wrap">
10617        <table id="compare-table">
10618          <colgroup>
10619            <col style="width:3%">
10620            <col style="width:12%">
10621            <col style="width:13%">
10622            <col style="width:9%">
10623            <col style="width:6%">
10624            <col style="width:9%">
10625            <col style="width:8%">
10626            <col style="width:6%">
10627            <col style="width:8%">
10628            <col style="width:14%">
10629            <col style="width:12%">
10630          </colgroup>
10631          <thead>
10632            <tr id="compare-thead">
10633              <th style="text-align:center;padding-left:4px;padding-right:4px;"><div class="col-resize-handle"></div></th>
10634              <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>
10635              <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>
10636              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
10637              <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>
10638              <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>
10639              <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>
10640              <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>
10641              <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>
10642              <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>
10643              <th>Submodules<div class="col-resize-handle"></div></th>
10644            </tr>
10645          </thead>
10646          <tbody id="compare-tbody">
10647            {% for entry in entries %}
10648            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
10649                data-timestamp="{{ entry.timestamp }}"
10650                data-project="{{ entry.project_label }}"
10651                data-files="{{ entry.files_analyzed }}"
10652                data-code="{{ entry.code_lines }}"
10653                data-comments="{{ entry.comment_lines }}"
10654                data-blank="{{ entry.blank_lines }}"
10655                data-branch="{{ entry.git_branch }}"
10656                data-commit="{{ entry.git_commit }}"
10657                data-submodules="{{ entry.submodule_names_csv }}">
10658              <td style="text-align:center;padding-left:4px;padding-right:4px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
10659              <td>{{ entry.timestamp }}</td>
10660              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
10661              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
10662              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
10663              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
10664              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
10665              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
10666              <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>
10667              <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>
10668              <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>
10669            </tr>
10670            {% endfor %}
10671          </tbody>
10672        </table>
10673      </div>
10674      <div class="pagination">
10675        <span class="pagination-info" id="pagination-info"></span>
10676        <div class="pagination-btns" id="pagination-btns"></div>
10677        <div class="flex-row">
10678          <span class="per-page-label">Show</span>
10679          <select class="per-page" id="per-page-sel">
10680            <option value="10">10 per page</option>
10681            <option value="25" selected>25 per page</option>
10682            <option value="50">50 per page</option>
10683            <option value="100">100 per page</option>
10684          </select>
10685          <span class="per-page-label" id="page-range-label"></span>
10686        </div>
10687      </div>
10688      {% endif %}
10689    </section>
10690  </div>
10691
10692  <footer class="site-footer">
10693    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
10694    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10695    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10696    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10697  </footer>
10698
10699  <script nonce="{{ csp_nonce }}">
10700    (function () {
10701      // ── Theme ──────────────────────────────────────────────────────────────
10702      var storageKey = 'oxide-sloc-theme';
10703      var body = document.body;
10704      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
10705      var toggle = document.getElementById('theme-toggle');
10706      if (toggle) toggle.addEventListener('click', function () {
10707        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
10708        body.classList.toggle('dark-theme', next === 'dark');
10709        try { localStorage.setItem(storageKey, next); } catch(e) {}
10710      });
10711
10712      // ── State ─────────────────────────────────────────────────────────────
10713      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
10714      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
10715      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
10716
10717      // ── Stat chips ────────────────────────────────────────────────────────
10718      (function() {
10719        var projects = {}, latestTs = '', latestRow = null;
10720        allRows.forEach(function(r) {
10721          var p = r.dataset.project || ''; if (p) projects[p] = true;
10722          var ts = r.dataset.timestamp || '';
10723          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
10724        });
10725        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
10726        if (latestRow) {
10727          var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
10728          var fe = document.getElementById('agg-files'); if (fe) fe.textContent = latestRow.dataset.files;
10729        }
10730      })();
10731
10732      // ── Branch filter population ──────────────────────────────────────────
10733      (function() {
10734        var branches = {};
10735        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
10736        var sel = document.getElementById('branch-filter');
10737        if (sel) Object.keys(branches).sort().forEach(function(b) {
10738          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
10739        });
10740      })();
10741
10742      // ── Filter ────────────────────────────────────────────────────────────
10743      function getFilteredRows() {
10744        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
10745        var branch = ((document.getElementById('branch-filter') || {}).value || '');
10746        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
10747          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
10748          if (branch && (r.dataset.branch || '') !== branch) return false;
10749          return true;
10750        });
10751      }
10752
10753      // ── Pagination ────────────────────────────────────────────────────────
10754      function renderPage() {
10755        var filtered = getFilteredRows();
10756        var total = filtered.length;
10757        var totalPages = Math.max(1, Math.ceil(total / perPage));
10758        currentPage = Math.min(currentPage, totalPages);
10759        var start = (currentPage - 1) * perPage;
10760        var end = Math.min(start + perPage, total);
10761        var shown = {};
10762        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
10763        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
10764          r.style.display = shown[r.dataset.run] ? '' : 'none';
10765        });
10766        var rl = document.getElementById('page-range-label');
10767        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
10768        var info = document.getElementById('pagination-info');
10769        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
10770        var btns = document.getElementById('pagination-btns');
10771        if (!btns) return;
10772        btns.innerHTML = '';
10773        function makeBtn(lbl, pg, active, disabled) {
10774          var b = document.createElement('button');
10775          b.className = 'pg-btn' + (active ? ' active' : '');
10776          b.textContent = lbl; b.disabled = disabled;
10777          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
10778          return b;
10779        }
10780        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
10781        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
10782        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
10783        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
10784      }
10785
10786      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
10787      window.applyFilters = function() { currentPage = 1; renderPage(); };
10788
10789      // ── Sorting ───────────────────────────────────────────────────────────
10790      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
10791      function doSort(col, type, order) {
10792        var tbody = document.getElementById('compare-tbody');
10793        if (!tbody) return;
10794        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
10795        rows.sort(function(a, b) {
10796          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
10797          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
10798          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
10799          return va < vb ? 1 : va > vb ? -1 : 0;
10800        });
10801        rows.forEach(function(r) { tbody.appendChild(r); });
10802        currentPage = 1; renderPage();
10803      }
10804      sortHeaders.forEach(function(th) {
10805        th.addEventListener('click', function(e) {
10806          if (e.target.classList.contains('col-resize-handle')) return;
10807          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
10808          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
10809          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
10810          th.classList.add('sort-' + sortOrder);
10811          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
10812          doSort(col, type, sortOrder);
10813        });
10814      });
10815
10816      // ── Column resize ─────────────────────────────────────────────────────
10817      (function() {
10818        var table = document.getElementById('compare-table');
10819        if (!table) return;
10820        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
10821        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
10822        ths.forEach(function(th, i) {
10823          var handle = th.querySelector('.col-resize-handle');
10824          if (!handle || !cols[i]) return;
10825          var startX, startW;
10826          handle.addEventListener('mousedown', function(e) {
10827            e.stopPropagation(); e.preventDefault();
10828            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
10829            handle.classList.add('dragging');
10830            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
10831            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
10832            document.addEventListener('mousemove', onMove);
10833            document.addEventListener('mouseup', onUp);
10834          });
10835        });
10836      })();
10837
10838      // ── Reset view ────────────────────────────────────────────────────────
10839      window.resetView = function() {
10840        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
10841        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
10842        sortCol = null; sortOrder = 'asc';
10843        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
10844        var tbody = document.getElementById('compare-tbody');
10845        if (tbody) {
10846          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
10847          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
10848          rows.forEach(function(r) { tbody.appendChild(r); });
10849        }
10850        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
10851        var table = document.getElementById('compare-table');
10852        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
10853        currentPage = 1; renderPage();
10854      };
10855
10856      renderPage();
10857
10858      // ── Row selection state ───────────────────────────────────────────────
10859      var selected = [];
10860      function updateCompareBtn() {
10861        var btn = document.getElementById('compare-btn');
10862        var cnt = document.getElementById('sel-count');
10863        if (!btn) return;
10864        btn.disabled = selected.length !== 2;
10865        if (cnt) cnt.textContent = selected.length + '/2';
10866      }
10867
10868      function toggleRow(row) {
10869        var vid = row.dataset.vid || row.dataset.run;
10870        var idx = selected.indexOf(vid);
10871        if (idx >= 0) {
10872          selected.splice(idx, 1);
10873          row.classList.remove('selected');
10874          var b = document.getElementById('badge-' + vid);
10875          if (b) b.textContent = '';
10876        } else {
10877          if (selected.length >= 2) return;
10878          selected.push(vid);
10879          row.classList.add('selected');
10880        }
10881        selected.forEach(function(v, i) {
10882          var b = document.getElementById('badge-' + v);
10883          if (b) b.textContent = i + 1;
10884        });
10885        updateCompareBtn();
10886        buildScopePanel();
10887      }
10888
10889      // ── Scope panel ───────────────────────────────────────────────────────
10890      var selectedScope = 'all';
10891
10892      function buildScopePanel() {
10893        var panel = document.getElementById('scope-panel');
10894        var opts = document.getElementById('scope-options');
10895        if (!panel || !opts) return;
10896        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
10897
10898        // Collect union of submodules from both selected rows.
10899        var allSubs = {};
10900        selected.forEach(function(vid) {
10901          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
10902          if (!row) return;
10903          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
10904        });
10905        var subList = Object.keys(allSubs).sort();
10906        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
10907
10908        panel.classList.remove('hidden');
10909        opts.innerHTML = '';
10910
10911        function makeOption(value, label, title) {
10912          var div = document.createElement('div');
10913          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
10914          div.dataset.scopeValue = value;
10915          if (title) div.title = title;
10916          var radio = document.createElement('span');
10917          radio.className = 'scope-option-radio';
10918          var lbl = document.createElement('span');
10919          lbl.textContent = label;
10920          div.appendChild(radio);
10921          div.appendChild(lbl);
10922          div.addEventListener('click', function() {
10923            selectedScope = value;
10924            opts.querySelectorAll('.scope-option').forEach(function(o) {
10925              o.classList.toggle('selected', o.dataset.scopeValue === value);
10926            });
10927          });
10928          return div;
10929        }
10930
10931        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
10932        var sep = document.createElement('span');
10933        sep.className = 'scope-option-sep';
10934        opts.appendChild(sep);
10935        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
10936        subList.forEach(function(s) {
10937          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
10938        });
10939      }
10940
10941      function doCompare() {
10942        if (selected.length !== 2) return;
10943        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
10944        if (selectedScope === 'super') url += '&scope=super';
10945        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
10946        window.location.href = url;
10947      }
10948
10949      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
10950      var cbtn = document.getElementById('compare-btn');
10951      if (cbtn) cbtn.addEventListener('click', doCompare);
10952      var pfEl = document.getElementById('project-filter');
10953      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
10954      var bfEl = document.getElementById('branch-filter');
10955      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
10956      var rvBtn = document.getElementById('reset-view-btn');
10957      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
10958      var ppSel = document.getElementById('per-page-sel');
10959      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
10960
10961      var cmpTbody = document.getElementById('compare-tbody');
10962      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
10963        var row = e.target.closest('.compare-row');
10964        if (row) toggleRow(row);
10965      });
10966
10967      (function randomizeWatermarks() {
10968        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10969        if (!wms.length) return;
10970        var placed = [];
10971        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;}
10972        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];}
10973        var half=Math.floor(wms.length/2);
10974        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;});
10975      })();
10976
10977      (function spawnCodeParticles() {
10978        var container = document.getElementById('code-particles');
10979        if (!container) return;
10980        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'];
10981        for (var i = 0; i < 38; i++) {
10982          (function(idx) {
10983            var el = document.createElement('span');
10984            el.className = 'code-particle';
10985            el.textContent = snippets[idx % snippets.length];
10986            var left = Math.random() * 94 + 2;
10987            var top = Math.random() * 88 + 6;
10988            var dur = (Math.random() * 10 + 9).toFixed(1);
10989            var delay = (Math.random() * 18).toFixed(1);
10990            var rot = (Math.random() * 26 - 13).toFixed(1);
10991            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
10992            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';
10993            container.appendChild(el);
10994          })(i);
10995        }
10996      })();
10997
10998      // ── Submodule chip truncation ─────────────────────────────────────────
10999      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
11000        var chips = cell.querySelectorAll('.submod-chip');
11001        var MAX = 4;
11002        if (chips.length <= MAX) return;
11003        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
11004        var badge = document.createElement('span');
11005        badge.className = 'submod-overflow-badge';
11006        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
11007        badge.textContent = '+' + (chips.length - MAX) + ' more';
11008        cell.appendChild(badge);
11009        cell.style.maxHeight = 'none';
11010      });
11011    })();
11012  </script>
11013</body>
11014</html>
11015"##,
11016    ext = "html"
11017)]
11018struct CompareSelectTemplate {
11019    version: &'static str,
11020    entries: Vec<HistoryEntryRow>,
11021    total_scans: usize,
11022    csp_nonce: String,
11023}
11024
11025// ── CompareTemplate ────────────────────────────────────────────────────────────
11026
11027#[derive(Template)]
11028#[template(
11029    source = r##"
11030<!doctype html>
11031<html lang="en">
11032<head>
11033  <meta charset="utf-8">
11034  <meta name="viewport" content="width=device-width, initial-scale=1">
11035  <title>OxideSLOC | Scan Delta</title>
11036  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11037  <style nonce="{{ csp_nonce }}">
11038    :root {
11039      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
11040      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
11041      --nav:#b85d33; --nav-2:#7a371b;
11042      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
11043      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
11044      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
11045    }
11046    body.dark-theme {
11047      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
11048      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
11049    }
11050    *{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);}
11051    .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);}
11052    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;}
11053    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;} .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));}
11054    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
11055    .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;}
11056    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
11057    .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;}
11058    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
11059    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
11060    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
11061    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
11062    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
11063    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
11064    .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;}
11065    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
11066    .hero-body{display:block;}
11067    .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;}
11068    .btn-back:hover{background:var(--line);}
11069    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
11070    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
11071    .muted{color:var(--muted);font-size:14px;}
11072    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
11073    .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;}
11074    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
11075    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
11076    .vpill-arrow{font-size:20px;color:var(--muted);}
11077    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
11078    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
11079    .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;}
11080    .delta-card.delta-card-wide{padding:22px 24px;}
11081    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
11082    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
11083    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
11084    .delta-card-from{font-size:15px;color:var(--muted);}
11085    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
11086    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
11087    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
11088    .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%;}
11089    .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;}
11090    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
11091    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
11092    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
11093    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
11094    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
11095    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
11096    .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;}
11097    .meta-card-commit:hover{color:var(--oxide);}
11098    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
11099    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
11100    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
11101    .meta-value{color:var(--text);font-size:13px;}
11102    .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;}
11103    .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);}
11104    .delta-card:hover .dc-tip{display:block;}
11105    .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;}
11106    .export-btn:hover{background:var(--line);}
11107    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
11108    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
11109    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
11110    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
11111    .delta-card-change.zero{color:var(--muted);background:transparent;}
11112    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
11113    .delta-card-pct.pos{color:var(--pos);}
11114    .delta-card-pct.neg{color:var(--neg);}
11115    .delta-card-pct.zero{color:var(--muted);}
11116    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
11117    .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;}
11118    .insight-card.insight-flag{border-color:var(--oxide);}
11119    .insight-card:hover .dc-tip{display:block;}
11120    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
11121    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
11122    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
11123    .insight-label.flag{color:var(--oxide);}
11124    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
11125    .insight-val.pos{color:var(--pos);}
11126    .insight-val.neg{color:var(--neg);}
11127    .insight-val.high{color:#c0392a;}
11128    .insight-val.med{color:#926000;}
11129    .insight-val.low{color:var(--pos);}
11130    body.dark-theme .insight-val.high{color:#ff6b6b;}
11131    body.dark-theme .insight-val.med{color:#f0c060;}
11132    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
11133    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
11134    .fc-row{display:flex;align-items:center;gap:8px;}
11135    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
11136    .fc-label{color:var(--muted);}
11137    .fc-modified .fc-count{color:#926000;}
11138    .fc-added .fc-count{color:var(--pos);}
11139    .fc-removed .fc-count{color:var(--neg);}
11140    .fc-unchanged .fc-count{color:var(--muted);}
11141    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
11142    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
11143    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
11144    .chip.modified{background:#fff2d8;color:#926000;}
11145    .chip.added{background:#e8f5ed;color:#1a8f47;}
11146    .chip.removed{background:#fdeaea;color:#b33b3b;}
11147    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
11148    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
11149    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
11150    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
11151    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
11152    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
11153    .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;}
11154    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
11155    .tab-btn:hover:not(.active){background:var(--line);}
11156    .btn-reset{padding:6px 14px;border-radius:8px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
11157    .btn-reset:hover{background:var(--line);}
11158    .table-wrap{width:100%;overflow-x:auto;}
11159    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
11160    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;}
11161    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
11162    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
11163    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
11164    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
11165    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
11166    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
11167    tr:last-child td{border-bottom:none;}
11168    tr.row-added td{background:rgba(26,143,71,0.06);}
11169    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
11170    tr.row-modified td{background:rgba(146,96,0,0.05);}
11171    tr.row-unchanged td{opacity:.6;}
11172    .file-path{font-family:ui-monospace,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
11173    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
11174    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
11175    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
11176    .status-badge.modified{background:#fff2d8;color:#926000;}
11177    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
11178    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
11179    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
11180    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
11181    .delta-val{font-weight:700;}
11182    .delta-val.pos{color:var(--pos);}
11183    .delta-val.neg{color:var(--neg);}
11184    .delta-val.zero{color:var(--muted);}
11185    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
11186    .from-to strong{color:var(--text);}
11187    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
11188    .site-footer a{color:var(--muted);}
11189    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
11190    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
11191    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
11192    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
11193    .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;}
11194    .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;}
11195    .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;}
11196    @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));}}
11197    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
11198    .path-link:hover{color:var(--oxide-2);}
11199    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
11200    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
11201    a.vpill-id:hover{color:var(--oxide);}
11202    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
11203    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
11204    .pagination-info{font-size:13px;color:var(--muted);}
11205    .pagination-btns{display:flex;gap:6px;}
11206    .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;}
11207    .pg-btn:hover:not(:disabled){background:var(--line);}
11208    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
11209    .pg-btn:disabled{opacity:.35;cursor:default;}
11210    .per-page-label{font-size:13px;color:var(--muted);}
11211    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;}
11212    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
11213    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
11214    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
11215    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
11216    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
11217    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
11218    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
11219    .tab-btn.tab-unchanged{color:var(--muted);}
11220    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
11221    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
11222    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
11223    .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;}.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;}
11224    .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;}
11225    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
11226    .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;}
11227    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
11228    .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;}
11229    .submod-scope-btn:hover{background:var(--line);}
11230    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
11231    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
11232  </style>
11233</head>
11234<body>
11235  <div class="background-watermarks" aria-hidden="true">
11236    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11237    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11238    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11239    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11240    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11241    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11242  </div>
11243  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11244  <div class="top-nav">
11245    <div class="top-nav-inner">
11246      <a class="brand" href="/">
11247        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11248        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
11249      </a>
11250      <div class="nav-right">
11251        <a class="nav-pill" href="/">Home</a>
11252        <a class="nav-pill" href="/view-reports">View Reports</a>
11253        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11254        <div class="nav-dropdown">
11255          <button class="nav-dropdown-btn" type="button">Git Tools <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></button>
11256          <div class="nav-dropdown-menu">
11257            <a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
11258            <a href="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Webhooks</a>
11259          </div>
11260        </div>
11261        <div class="server-status-wrap">
11262          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
11263          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
11264        </div>
11265        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11266          <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>
11267          <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>
11268        </button>
11269      </div>
11270    </div>
11271  </div>
11272
11273  <div class="page">
11274    <section class="hero">
11275      <div class="hero-header">
11276        <div>
11277          <h1 style="margin:0 0 6px;">Scan Delta</h1>
11278          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
11279            {% if let Some(sub) = active_submodule %}
11280            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
11281            {% else if super_scope_active %}
11282            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
11283            {% else %}
11284            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
11285            {% endif %}
11286            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
11287          </div>
11288        </div>
11289        <a class="btn-back" href="/compare-scans">
11290          <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>
11291          Compare Scans
11292        </a>
11293      </div>
11294      {% if has_any_submodule_data %}
11295      <div class="submod-scope-bar">
11296        <span class="submod-scope-label">
11297          <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>
11298          Scope:
11299        </span>
11300        <div class="submod-scope-divider"></div>
11301        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
11302           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
11303           title="All files — super-repo and all submodules combined">Full scan</a>
11304        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
11305           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
11306           title="Only files that are not part of any submodule">Super-repo only</a>
11307        {% for sub in submodule_options %}
11308        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
11309           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
11310           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
11311        {% endfor %}
11312      </div>
11313      {% endif %}
11314      <div class="hero-body">
11315      <div class="meta-strip">
11316        <div class="delta-card delta-card-meta">
11317          <div class="meta-card-header">
11318            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
11319            <div class="meta-card-project-col">
11320              <div class="meta-card-project">{{ project_name }}</div>
11321              {% if has_any_submodule_data %}
11322              {% if let Some(sub) = active_submodule %}
11323              <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>
11324              {% else if super_scope_active %}
11325              <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>
11326              {% else %}
11327              <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>
11328              {% endif %}
11329              {% endif %}
11330            </div>
11331          </div>
11332          {% if !baseline_git_commit.is_empty() %}
11333          <a class="meta-card-commit" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_git_commit }}</a>
11334          {% else %}
11335          <a class="meta-card-commit" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_run_id_short }}</a>
11336          {% endif %}
11337          <div class="meta-card-rows">
11338            <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>
11339            <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>
11340            <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = baseline_git_author %}<span class="meta-value">{{ author }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
11341            <div class="meta-card-row"><span class="meta-label">Scanned on:</span><span class="meta-value">{{ baseline_timestamp }}</span></div>
11342            {% if let Some(tags) = baseline_git_tags %}
11343            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
11344            {% endif %}
11345          </div>
11346        </div>
11347        <div class="delta-card delta-card-meta">
11348          <div class="meta-card-header">
11349            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
11350            <div class="meta-card-project-col">
11351              <div class="meta-card-project">{{ project_name }}</div>
11352              {% if has_any_submodule_data %}
11353              {% if let Some(sub) = active_submodule %}
11354              <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>
11355              {% else if super_scope_active %}
11356              <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>
11357              {% else %}
11358              <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>
11359              {% endif %}
11360              {% endif %}
11361            </div>
11362          </div>
11363          {% if !current_git_commit.is_empty() %}
11364          <a class="meta-card-commit" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_git_commit }}</a>
11365          {% else %}
11366          <a class="meta-card-commit" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_run_id_short }}</a>
11367          {% endif %}
11368          <div class="meta-card-rows">
11369            <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>
11370            <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>
11371            <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = current_git_author %}<span class="meta-value">{{ author }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
11372            <div class="meta-card-row"><span class="meta-label">Scanned on:</span><span class="meta-value">{{ current_timestamp }}</span></div>
11373            {% if let Some(tags) = current_git_tags %}
11374            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
11375            {% endif %}
11376          </div>
11377        </div>
11378      </div>
11379      <div class="delta-strip">
11380        <div class="delta-card">
11381          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
11382          <div class="delta-card-label">Code lines</div>
11383          <div class="delta-card-from">Before: {{ baseline_code }}</div>
11384          <div class="delta-card-to">{{ current_code }}</div>
11385          {% 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>
11386          {% 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>
11387          {% else %}<div class="delta-card-pct zero">±0%</div>
11388          {% endif %}
11389        </div>
11390        <div class="delta-card">
11391          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
11392          <div class="delta-card-label">Files analyzed</div>
11393          <div class="delta-card-from">Before: {{ baseline_files }}</div>
11394          <div class="delta-card-to">{{ current_files }}</div>
11395          {% 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>
11396          {% 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>
11397          {% else %}<div class="delta-card-pct zero">±0%</div>
11398          {% endif %}
11399        </div>
11400        <div class="delta-card">
11401          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
11402          <div class="delta-card-label">Comment lines</div>
11403          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
11404          <div class="delta-card-to">{{ current_comments }}</div>
11405          {% 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>
11406          {% 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>
11407          {% else %}<div class="delta-card-pct zero">±0%</div>
11408          {% endif %}
11409        </div>
11410        <div class="delta-card delta-card-wide">
11411          <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>
11412          <div class="delta-card-label">File changes</div>
11413          <div class="file-changes-grid">
11414            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
11415            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
11416            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
11417            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
11418          </div>
11419        </div>
11420      </div>
11421      <div class="insights-panel">
11422        <div class="insight-card">
11423          <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>
11424          <div class="insight-label">Lines Added</div>
11425          <div class="insight-val pos">+{{ code_lines_added }}</div>
11426          <div class="insight-sub">New or grown source lines</div>
11427        </div>
11428        <div class="insight-card">
11429          <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>
11430          <div class="insight-label">Lines Removed</div>
11431          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
11432          <div class="insight-sub">Deleted or shrunk source lines</div>
11433        </div>
11434        <div class="insight-card">
11435          <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>
11436          <div class="insight-label">Churn Rate</div>
11437          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
11438          <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>
11439        </div>
11440        {% if scope_flag %}
11441        <div class="insight-card insight-flag">
11442          <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>
11443          <div class="insight-label flag">Scope Signal</div>
11444          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
11445          <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>
11446        </div>
11447        {% endif %}
11448      </div>
11449      </div>
11450    </section>
11451
11452    <section class="panel">
11453      <h2>File-level delta</h2>
11454      <div class="filter-tabs-row">
11455        <div class="filter-tabs">
11456          <button class="tab-btn tab-all active" data-filter="all">All</button>
11457          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
11458          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
11459          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
11460          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
11461        </div>
11462        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
11463          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
11464          <div class="export-group">
11465            <button type="button" class="btn-reset" id="delta-reset-btn">&#8635; Reset</button>
11466            <button type="button" class="export-btn" id="delta-csv-btn">
11467              <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>
11468              CSV
11469            </button>
11470            <button type="button" class="export-btn" id="delta-xls-btn">
11471              <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>
11472              Excel
11473            </button>
11474            <button type="button" class="export-btn" id="delta-charts-btn">
11475              <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>
11476              Charts
11477            </button>
11478          </div>
11479        </div>
11480      </div>
11481
11482      <div class="table-wrap">
11483      <table id="delta-table">
11484        <colgroup>
11485          <col style="width:55%">
11486          <col style="width:7%">
11487          <col style="width:7%">
11488          <col style="width:12%">
11489          <col style="width:6%">
11490          <col style="width:6%">
11491          <col style="width:7%">
11492        </colgroup>
11493        <thead>
11494          <tr id="delta-thead">
11495            <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>
11496            <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>
11497            <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>
11498            <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>
11499            <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>
11500            <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>
11501            <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>
11502          </tr>
11503        </thead>
11504        <tbody id="delta-tbody">
11505          {% for row in file_rows %}
11506          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
11507              data-path="{{ row.relative_path }}"
11508              data-language="{{ row.language }}"
11509              data-baseline-code="{{ row.baseline_code }}"
11510              data-current-code="{{ row.current_code }}"
11511              data-code-delta="{{ row.code_delta_str }}"
11512              data-comment-delta="{{ row.comment_delta_str }}"
11513              data-total-delta="{{ row.total_delta_str }}"
11514              data-orig-idx="">
11515            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
11516            <td class="hide-sm">{{ row.language }}</td>
11517            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
11518            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
11519            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
11520            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
11521            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
11522          </tr>
11523          {% endfor %}
11524        </tbody>
11525      </table>
11526      </div>
11527      <div class="pagination">
11528        <span class="pagination-info" id="pg-info"></span>
11529        <div class="pagination-btns" id="pg-btns"></div>
11530        <div class="flex-row">
11531          <span class="per-page-label">Show</span>
11532          <select class="per-page" id="per-page-sel">
11533            <option value="10">10 per page</option>
11534            <option value="25" selected>25 per page</option>
11535            <option value="50">50 per page</option>
11536            <option value="100">100 per page</option>
11537          </select>
11538          <span class="per-page-label" id="pg-range-label"></span>
11539        </div>
11540      </div>
11541    </section>
11542  </div>
11543
11544  <footer class="site-footer">
11545    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
11546    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
11547    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
11548    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
11549  </footer>
11550
11551  <script nonce="{{ csp_nonce }}">
11552    (function () {
11553      var storageKey = 'oxide-sloc-theme';
11554      var body = document.body;
11555      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
11556      var toggle = document.getElementById('theme-toggle');
11557      if (toggle) toggle.addEventListener('click', function () {
11558        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
11559        body.classList.toggle('dark-theme', next === 'dark');
11560        try { localStorage.setItem(storageKey, next); } catch(e) {}
11561      });
11562
11563      (function randomizeWatermarks() {
11564        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11565        if (!wms.length) return;
11566        var placed = [];
11567        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;}
11568        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];}
11569        var half=Math.floor(wms.length/2);
11570        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;});
11571      })();
11572
11573      (function spawnCodeParticles() {
11574        var container = document.getElementById('code-particles');
11575        if (!container) return;
11576        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'];
11577        for (var i = 0; i < 38; i++) {
11578          (function(idx) {
11579            var el = document.createElement('span');
11580            el.className = 'code-particle';
11581            el.textContent = snippets[idx % snippets.length];
11582            var left = Math.random() * 94 + 2;
11583            var top = Math.random() * 88 + 6;
11584            var dur = (Math.random() * 10 + 9).toFixed(1);
11585            var delay = (Math.random() * 18).toFixed(1);
11586            var rot = (Math.random() * 26 - 13).toFixed(1);
11587            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
11588            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';
11589            container.appendChild(el);
11590          })(i);
11591        }
11592      })();
11593    })();
11594
11595    var activeStatusFilter = 'all';
11596    var deltaPerPage = 25, deltaCurrPage = 1;
11597
11598    function openFolder(path) {
11599      fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
11600    }
11601
11602    function getDeltaFilteredRows() {
11603      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
11604        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
11605      });
11606    }
11607
11608    function renderDeltaPage() {
11609      var filtered = getDeltaFilteredRows();
11610      var total = filtered.length;
11611      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
11612      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
11613      var start = (deltaCurrPage - 1) * deltaPerPage;
11614      var end = Math.min(start + deltaPerPage, total);
11615      var shownSet = {};
11616      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
11617      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
11618        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
11619      });
11620      var rl = document.getElementById('pg-range-label');
11621      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
11622      var info = document.getElementById('pg-info');
11623      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
11624      var btns = document.getElementById('pg-btns');
11625      if (!btns) return;
11626      btns.innerHTML = '';
11627      if (totalPages <= 1) return;
11628      function makeBtn(lbl, pg, active, disabled) {
11629        var b = document.createElement('button');
11630        b.className = 'pg-btn' + (active ? ' active' : '');
11631        b.textContent = lbl; b.disabled = disabled;
11632        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
11633        return b;
11634      }
11635      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
11636      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
11637      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
11638      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
11639    }
11640
11641    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
11642
11643    function filterRows(status, btn) {
11644      activeStatusFilter = status;
11645      deltaCurrPage = 1;
11646      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
11647        b.classList.remove('active');
11648      });
11649      if (btn) btn.classList.add('active');
11650      renderDeltaPage();
11651    }
11652
11653    // ── Sorting ──────────────────────────────────────────────────────────────
11654    var sortCol = null, sortOrder = 'asc';
11655    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
11656    (function() {
11657      var tbody = document.getElementById('delta-tbody');
11658      if (!tbody) return;
11659      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
11660      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
11661    })();
11662
11663    function parseDeltaNum(str) {
11664      if (!str || str === '—') return 0;
11665      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
11666    }
11667
11668    sortHeaders.forEach(function(th) {
11669      th.addEventListener('click', function(e) {
11670        if (e.target.classList.contains('col-resize-handle')) return;
11671        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
11672        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
11673        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
11674        th.classList.add('sort-' + sortOrder);
11675        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
11676        var tbody = document.getElementById('delta-tbody');
11677        if (!tbody) return;
11678        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
11679        rows.sort(function(a, b) {
11680          var va, vb;
11681          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
11682          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
11683          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
11684          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
11685          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
11686          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
11687          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
11688          else { va = ''; vb = ''; }
11689          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
11690          return va < vb ? 1 : va > vb ? -1 : 0;
11691        });
11692        rows.forEach(function(r) { tbody.appendChild(r); });
11693        deltaCurrPage = 1;
11694        renderDeltaPage();
11695        var activeBtn = document.querySelector('.tab-btn.active');
11696        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
11697        if (activeBtn) activeBtn.classList.add('active');
11698      });
11699    });
11700
11701    // ── Column resize ─────────────────────────────────────────────────────────
11702    (function() {
11703      var table = document.getElementById('delta-table');
11704      if (!table) return;
11705      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
11706      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
11707      ths.forEach(function(th, i) {
11708        var handle = th.querySelector('.col-resize-handle');
11709        if (!handle || !cols[i]) return;
11710        var startX, startW;
11711        handle.addEventListener('mousedown', function(e) {
11712          e.stopPropagation(); e.preventDefault();
11713          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
11714          handle.classList.add('dragging');
11715          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
11716          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
11717          document.addEventListener('mousemove', onMove);
11718          document.addEventListener('mouseup', onUp);
11719        });
11720      });
11721    })();
11722
11723    // ── Reset ─────────────────────────────────────────────────────────────────
11724    window.resetDeltaTable = function() {
11725      sortCol = null; sortOrder = 'asc';
11726      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
11727      var tbody = document.getElementById('delta-tbody');
11728      if (tbody) {
11729        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
11730        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
11731        rows.forEach(function(r) { tbody.appendChild(r); });
11732      }
11733      var table = document.getElementById('delta-table');
11734      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
11735      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
11736      activeStatusFilter = 'all';
11737      deltaCurrPage = 1;
11738      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
11739      var allBtn = document.querySelector('.tab-btn');
11740      if (allBtn) allBtn.classList.add('active');
11741      renderDeltaPage();
11742    };
11743
11744    renderDeltaPage();
11745
11746    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
11747    (function() {
11748      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
11749        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
11750      });
11751      var resetBtn = document.getElementById('delta-reset-btn');
11752      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
11753      var csvBtn = document.getElementById('delta-csv-btn');
11754      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
11755      var xlsBtn = document.getElementById('delta-xls-btn');
11756      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
11757      var chartsBtn = document.getElementById('delta-charts-btn');
11758      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
11759      var ppSel = document.getElementById('per-page-sel');
11760      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
11761      var pathLink = document.getElementById('project-path-link');
11762      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
11763    })();
11764
11765    // ── Export helpers ────────────────────────────────────────────────────────
11766    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
11767    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
11768    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);}
11769    function slocMakeXlsx(fname,sd,dr){
11770      var enc=new TextEncoder();
11771      // CRC-32 table
11772      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;}
11773      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;}
11774      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
11775      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
11776      // Shared string table
11777      var ss=[],si={};
11778      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
11779      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
11780      // Worksheet builder — each WS() call gets its own row counter R
11781      function WS(){
11782        var R=0,buf=[];
11783        function cl(c){return String.fromCharCode(65+c);}
11784        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
11785          '<v>'+S(v)+'</v></c>';}
11786        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
11787          (st?' s="'+st+'"':'')+'>'+
11788          '<v>'+(+v)+'</v></c>';}
11789        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
11790        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
11791          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
11792          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
11793          '<sheetFormatPr defaultRowHeight="15"/>'+
11794          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
11795        return{sc:sc,nc:nc,row:row,xml:xml};
11796      }
11797      // Language breakdown
11798      var lm={};
11799      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;});
11800      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
11801      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
11802      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
11803      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
11804      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
11805      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
11806      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):'';}
11807      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
11808      // Summary sheet
11809      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11810      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
11811      r1(s1(0,proj,2));
11812      r1(s1(0,sd.bts+' → '+sd.cts,2));
11813      r1('');
11814      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
11815      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))));
11816      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))));
11817      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))));
11818      r1('');
11819      r1(s1(0,'FILE CHANGES',8));
11820      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
11821      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
11822      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
11823      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
11824      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
11825      if(langs.length){
11826        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11827        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
11828        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)));});
11829      }
11830      r1('');r1(s1(0,'SCAN METADATA',8));
11831      r1(s1(1,_blabel)+s1(2,_clabel));
11832      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
11833      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
11834      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"/>');
11835      // File Delta sheet
11836      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11837      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));
11838      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)));});
11839      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
11840      // Shared strings XML
11841      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
11842        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
11843        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
11844      // XLSX file map
11845      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11846      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>',
11847        '_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>',
11848        '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>',
11849        '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>',
11850        '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>',
11851        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
11852      // ZIP packer — STORED (no compression), compatible with all XLSX readers
11853      var zparts=[],zcds=[],zoff=0,znf=0;
11854      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
11855       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
11856      ].forEach(function(name){
11857        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11858        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]);
11859        var entry=new Uint8Array(lha.length+nb.length+sz);
11860        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
11861        zparts.push(entry);
11862        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));
11863        var cde=new Uint8Array(cda.length+nb.length);
11864        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
11865        zcds.push(cde);zoff+=entry.length;znf++;
11866      });
11867      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
11868      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]);
11869      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
11870      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
11871      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
11872      zout.set(new Uint8Array(ea),zpos);
11873      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
11874      var xurl=URL.createObjectURL(xblob);
11875      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
11876      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
11877      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
11878    }
11879    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;');}
11880    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
11881    function getExportFilename(ext){return _exportBase+'.'+ext;}
11882
11883    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 }}'};
11884    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;}
11885    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
11886    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
11887    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
11888    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
11889    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):'';}
11890    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
11891    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)]];}
11892    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
11893    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;}
11894    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
11895    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
11896
11897    // ── Chart HTML report ─────────────────────────────────────────────────────
11898    function slocChartReport(fname, sd, dr) {
11899      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
11900      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
11901      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
11902      function fmt(n){return Number(n).toLocaleString();}
11903      function px(n){return Math.round(n);}
11904      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
11905      // Language map
11906      var lm={};
11907      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;});
11908      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
11909
11910      // Builds onmouse* attrs for interactive tooltip on each SVG element
11911      function barTT(label,val){
11912        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
11913      }
11914
11915      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
11916      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc},{l:'Files Analyzed',b:sd.bf,c:sd.cf},{l:'Comments',b:sd.bcm,c:sd.ccm}];
11917      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
11918      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
11919      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
11920      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11921      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"/>';}
11922      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
11923      c1mets.forEach(function(m,i){
11924        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11925        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11926        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>';
11927        c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+GY+'" rx="3"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
11928        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="#666">'+fmt(m.b)+'</text>';
11929        c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+OX+'" rx="3"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
11930        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="'+OX+'">'+fmt(m.c)+'</text>';
11931        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>';
11932        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+OX+'">After</text>';
11933      });
11934      c1+='</svg>';
11935
11936      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
11937      var mets=[{l:'Code Lines',v:sd.cc-sd.bc},{l:'Files Analyzed',v:sd.cf-sd.bf},{l:'Comment Lines',v:sd.ccm-sd.bcm}];
11938      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
11939      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
11940      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
11941      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11942      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11943      mets.forEach(function(m,i){
11944        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
11945        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
11946        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
11947        c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" fill="#444">'+esc(m.l)+'</text>';
11948        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
11949        if(bw>=52){
11950          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>';
11951        }else{
11952          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
11953          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>';
11954        }
11955      });
11956      c2+='</svg>';
11957
11958      // ── Chart 3: Language Code Delta ─────────────────────────────────────
11959      var c3='';
11960      if(langs.length){
11961        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
11962        var C3W=550,c3LW=124,c3FW=52;
11963        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
11964        var L3rH=30,C3H=langs.length*L3rH+20;
11965        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11966        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11967        langs.forEach(function(l,i){
11968          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
11969          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
11970          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
11971          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
11972          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':''))+'/>';
11973          if(bw>=48){
11974            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>';
11975          }else{
11976            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
11977            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>';
11978          }
11979          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>';
11980        });
11981        c3+='</svg>';
11982      }
11983
11984      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
11985      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;});
11986      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
11987      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
11988      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11989      var ang=-Math.PI/2;
11990      segs.forEach(function(s){
11991        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
11992        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
11993        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
11994        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
11995        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
11996        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)+'%')+'/>';
11997        ang+=sw;
11998      });
11999      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>';
12000      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
12001      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>';});
12002      c4+='</svg>';
12003
12004      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
12005      var ttJs='var tt=document.getElementById("ox-tt");'+
12006        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
12007        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
12008        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
12009        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
12010        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
12011        'function oxHT(){tt.style.display="none";}';
12012
12013      // body max-width keeps charts from inflating beyond design dimensions on
12014      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
12015      // each chart's height blows up proportionally, breaking the one-page layout.
12016      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;}'+
12017        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
12018        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
12019        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
12020        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
12021        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
12022        'svg{display:block;}'+
12023        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
12024        '#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;}'+
12025        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
12026      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
12027        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
12028        '<div id="ox-tt"><\/div>'+
12029        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
12030        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
12031        '<div class="two-col">'+
12032        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
12033        '<div class="leg"><span><span class="dot" style="background:#AAAAAA"><\/span>Baseline<\/span>'+
12034        '<span><span class="dot" style="background:#C45C10"><\/span>Current<\/span><\/div>'+c1+'<\/div>'+
12035        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
12036        '<\/div>'+
12037        '<div class="two-col">'+
12038        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
12039        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
12040        '<\/div>'+
12041        '<script>'+ttJs+'<\/script>'+
12042        '<\/body><\/html>';
12043      slocDownload(html, fname, 'text/html;charset=utf-8;');
12044    }
12045    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
12046  </script>
12047</body>
12048</html>
12049"##,
12050    ext = "html"
12051)]
12052struct CompareTemplate {
12053    version: &'static str,
12054    project_label: String,
12055    baseline_git_commit: String,
12056    current_git_commit: String,
12057    baseline_run_id: String,
12058    current_run_id: String,
12059    baseline_run_id_short: String,
12060    current_run_id_short: String,
12061    baseline_timestamp: String,
12062    current_timestamp: String,
12063    project_path: String,
12064    baseline_code: u64,
12065    current_code: u64,
12066    code_lines_delta_str: String,
12067    code_lines_delta_class: String,
12068    baseline_files: u64,
12069    current_files: u64,
12070    files_analyzed_delta_str: String,
12071    files_analyzed_delta_class: String,
12072    baseline_comments: u64,
12073    current_comments: u64,
12074    comment_lines_delta_str: String,
12075    comment_lines_delta_class: String,
12076    code_lines_pct_str: String,
12077    files_analyzed_pct_str: String,
12078    comment_lines_pct_str: String,
12079    code_lines_added: i64,
12080    code_lines_removed: i64,
12081    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
12082    new_scope: bool,
12083    churn_rate_str: String,
12084    churn_rate_class: String,
12085    scope_flag: bool,
12086    files_added: usize,
12087    files_removed: usize,
12088    files_modified: usize,
12089    files_unchanged: usize,
12090    file_rows: Vec<CompareFileDeltaRow>,
12091    baseline_git_author: Option<String>,
12092    current_git_author: Option<String>,
12093    baseline_git_branch: String,
12094    current_git_branch: String,
12095    baseline_git_tags: Option<String>,
12096    current_git_tags: Option<String>,
12097    baseline_git_commit_date: Option<String>,
12098    current_git_commit_date: Option<String>,
12099    project_name: String,
12100    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
12101    submodule_options: Vec<String>,
12102    /// True when either run has submodule data — controls whether the scope bar is shown.
12103    has_any_submodule_data: bool,
12104    /// The submodule currently being compared, if the `sub` query param was provided.
12105    active_submodule: Option<String>,
12106    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
12107    super_scope_active: bool,
12108    csp_nonce: String,
12109}
12110
12111// ── LoginTemplate ──────────────────────────────────────────────────────────────
12112
12113#[derive(Template)]
12114#[template(
12115    source = r##"
12116<!doctype html>
12117<html lang="en">
12118<head>
12119  <meta charset="utf-8">
12120  <meta name="viewport" content="width=device-width, initial-scale=1">
12121  <title>OxideSLOC | Sign In</title>
12122  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12123  <style nonce="{{ csp_nonce }}">
12124    :root {
12125      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
12126      --text:#2f241c; --muted:#7b675b; --nav:#b85d33; --nav-2:#7a371b;
12127      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
12128      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
12129    }
12130    *{box-sizing:border-box;}
12131    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);}
12132    .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);}
12133    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
12134    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
12135    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
12136    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;}
12137    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
12138    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
12139    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
12140    .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;}
12141    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
12142    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;}
12143    input[type=password]:focus{border-color:var(--oxide);}
12144    .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;}
12145    .btn:hover{opacity:.88;}
12146    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
12147    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
12148  </style>
12149</head>
12150<body>
12151<nav class="top-nav">
12152  <a class="brand" href="/">
12153    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
12154    <span class="brand-title">OxideSLOC</span>
12155  </a>
12156</nav>
12157<main class="page">
12158  <div class="card">
12159    <h1>Sign In</h1>
12160    <p class="subtitle">Enter the API key printed when the server started.</p>
12161    {% if has_error %}
12162    <div class="error">Incorrect API key — please try again.</div>
12163    {% endif %}
12164    <form method="POST" action="/auth/login">
12165      <input type="hidden" name="next" value="{{ next_url|e }}">
12166      <label for="key">API Key</label>
12167      <input id="key" type="password" name="key" autocomplete="current-password"
12168             placeholder="Paste your API key here" autofocus>
12169      <button type="submit" class="btn">Sign In</button>
12170    </form>
12171    <p class="hint">
12172      The API key was printed in the terminal when the server started.<br>
12173      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
12174      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
12175    </p>
12176  </div>
12177</main>
12178</body>
12179</html>
12180"##,
12181    ext = "html"
12182)]
12183struct LoginTemplate {
12184    csp_nonce: String,
12185    has_error: bool,
12186    next_url: String,
12187    lockout_threshold: u32,
12188}