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
5pub(crate) mod git_browser;
6pub(crate) mod git_webhook;
7
8use std::{
9    collections::{HashMap, VecDeque},
10    fmt::Write,
11    fs,
12    net::{IpAddr, SocketAddr},
13    path::{Path, PathBuf},
14    process::Stdio,
15    sync::Arc,
16    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
17};
18
19use anyhow::{Context, Result};
20use askama::Template;
21use axum::{
22    body::Body,
23    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
24    http::{header, HeaderValue, Request, StatusCode},
25    middleware::{self, Next},
26    response::{Html, IntoResponse, Response},
27    routing::{get, post},
28    Json, Router,
29};
30use serde::{Deserialize, Serialize};
31use tokio::sync::Mutex;
32use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
33
34use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
35use sloc_git::ScheduleStore;
36
37#[derive(Clone)]
38struct CspNonce(String);
39
40static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
41
42use sloc_core::{
43    analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
44    ScanSummarySnapshot, SummaryTotals,
45};
46use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
47const MAX_CONCURRENT_ANALYSES: usize = 4;
48
49/// Sliding-window rate limiter keyed by client IP.
50/// Uses only std primitives — no external crate required.
51struct IpRateLimiter {
52    window: Duration,
53    max_requests: usize,
54    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
55    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
56}
57
58impl IpRateLimiter {
59    fn new(window: Duration, max_requests: usize) -> Self {
60        Self {
61            window,
62            max_requests,
63            state: std::sync::Mutex::new(HashMap::new()),
64            auth_failures: std::sync::Mutex::new(HashMap::new()),
65        }
66    }
67
68    // The MutexGuard `state` must live as long as `bucket` borrows from it,
69    // so it cannot be dropped any earlier than the end of the inner block.
70    #[allow(clippy::significant_drop_tightening)]
71    fn is_allowed(&self, ip: IpAddr) -> bool {
72        let now = Instant::now();
73        let cutoff = now.checked_sub(self.window).unwrap_or(now);
74        let mut state = self
75            .state
76            .lock()
77            .unwrap_or_else(std::sync::PoisonError::into_inner);
78        if state.len() > 10_000 {
79            state.retain(|_, bucket| {
80                while bucket.front().is_some_and(|t| *t <= cutoff) {
81                    bucket.pop_front();
82                }
83                !bucket.is_empty()
84            });
85        }
86        let bucket = state.entry(ip).or_default();
87        while bucket.front().is_some_and(|t| *t <= cutoff) {
88            bucket.pop_front();
89        }
90        if bucket.len() >= self.max_requests {
91            false
92        } else {
93            bucket.push_back(now);
94            true
95        }
96    }
97
98    fn record_auth_failure(&self, ip: IpAddr) {
99        let now = Instant::now();
100        let mut map = self
101            .auth_failures
102            .lock()
103            .unwrap_or_else(std::sync::PoisonError::into_inner);
104        map.entry(ip)
105            .and_modify(|e| {
106                e.0 += 1;
107                e.1 = now;
108            })
109            .or_insert_with(|| (1, now));
110    }
111
112    fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
113        const LOCKOUT_THRESHOLD: u32 = 10;
114        const LOCKOUT_WINDOW: Duration = Duration::from_hours(1);
115        let mut map = self
116            .auth_failures
117            .lock()
118            .unwrap_or_else(std::sync::PoisonError::into_inner);
119        let expired = map.get(&ip).is_some_and(|e| e.1.elapsed() > LOCKOUT_WINDOW);
120        if expired {
121            map.remove(&ip);
122            return false;
123        }
124        map.get(&ip).is_some_and(|e| e.0 >= LOCKOUT_THRESHOLD)
125    }
126}
127
128#[derive(Clone)]
129struct AppState {
130    base_config: AppConfig,
131    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
132    registry: Arc<Mutex<ScanRegistry>>,
133    registry_path: PathBuf,
134    analyze_semaphore: Arc<tokio::sync::Semaphore>,
135    server_mode: bool,
136    tls_enabled: bool,
137    api_keys: Vec<secrecy::Secret<String>>,
138    rate_limiter: Arc<IpRateLimiter>,
139    trust_proxy: bool,
140    /// Directory where remote repositories are cloned for git-browser scans.
141    git_clones_dir: PathBuf,
142    /// Persisted list of webhook / poll schedules.
143    schedules: Arc<Mutex<ScheduleStore>>,
144    schedules_path: PathBuf,
145}
146
147type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
148
149#[derive(Clone, Debug)]
150struct RunArtifacts {
151    output_dir: PathBuf,
152    html_path: Option<PathBuf>,
153    pdf_path: Option<PathBuf>,
154    json_path: Option<PathBuf>,
155    report_title: String,
156}
157
158/// # Errors
159///
160/// Returns an error if the server fails to bind to the configured address or
161/// if the TLS configuration cannot be loaded.
162///
163/// # Panics
164///
165/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
166// The function coordinates TLS setup, router construction, and async listener setup in one
167// place; splitting it further would require passing many state values across function boundaries.
168#[allow(clippy::too_many_lines)]
169pub async fn serve(config: AppConfig) -> Result<()> {
170    let bind_address = config.web.bind_address.clone();
171    let server_mode = config.web.server_mode;
172    let output_root = resolve_output_root(None);
173    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
174    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
175        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
176    let mut registry = ScanRegistry::load(&registry_path);
177    registry.prune_stale();
178    let _ = registry.save(&registry_path);
179
180    let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
181        .or_else(|_| std::env::var("SLOC_API_KEY"))
182        .unwrap_or_default()
183        .split(',')
184        .map(str::trim)
185        .filter(|s| !s.is_empty())
186        .map(|s| secrecy::Secret::new(s.to_owned()))
187        .collect();
188    if server_mode && api_keys.is_empty() {
189        println!(
190            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
191             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
192        );
193    }
194
195    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
196    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
197    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
198    if server_mode && !tls_enabled {
199        println!(
200            "WARNING: TLS is not configured. Traffic is cleartext. \
201             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
202             or terminate TLS at a reverse proxy (nginx, caddy)."
203        );
204    }
205    if server_mode {
206        println!(
207            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
208             to restrict cross-origin access (comma-separated)."
209        );
210    }
211    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
212    if trust_proxy {
213        println!(
214            "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
215             Only set this when oxide-sloc is behind a trusted reverse proxy."
216        );
217    }
218
219    // 60 req/min per IP across all routes.
220    let rate_limiter = Arc::new(IpRateLimiter::new(Duration::from_mins(1), 60));
221
222    let git_clones_dir = resolve_git_clones_dir(&output_root);
223    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
224        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
225    let schedules = ScheduleStore::load(&schedules_path);
226
227    let state = AppState {
228        base_config: config,
229        artifacts: Arc::new(Mutex::new(HashMap::new())),
230        registry: Arc::new(Mutex::new(registry)),
231        registry_path,
232        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
233        server_mode,
234        tls_enabled,
235        api_keys,
236        rate_limiter,
237        trust_proxy,
238        git_clones_dir,
239        schedules: Arc::new(Mutex::new(schedules)),
240        schedules_path,
241    };
242
243    restart_poll_schedules(&state).await;
244
245    let protected = Router::new()
246        .route("/", get(splash))
247        .route("/scan-setup", get(scan_setup_handler))
248        .route("/scan", get(index))
249        .route("/analyze", post(analyze_handler))
250        .route("/preview", get(preview_handler))
251        .route("/pick-directory", get(pick_directory_handler))
252        .route("/open-path", get(open_path_handler))
253        .route("/pick-file", get(pick_file_handler))
254        .route("/locate-report", post(locate_report_handler))
255        .route("/view-reports", get(history_handler))
256        .route("/compare-scans", get(compare_select_handler))
257        .route("/compare", get(compare_handler))
258        .route("/images/{folder}/{file}", get(image_handler))
259        .route("/runs/{run_id}/{artifact}", get(artifact_handler))
260        .route("/api/metrics/latest", get(api_metrics_latest_handler))
261        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
262        .route("/api/project-history", get(project_history_handler))
263        .route("/embed/summary", get(embed_handler))
264        // ── Git browser ────────────────────────────────────────────────────────
265        .route("/git-browser", get(git_browser::git_browser_handler))
266        .route("/api/git/refs", get(git_browser::api_list_refs))
267        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
268        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
269        // ── Webhook + schedule management ──────────────────────────────────────
270        .route("/webhook-setup", get(git_webhook::webhook_setup_handler))
271        .route("/api/schedules", get(git_webhook::api_list_schedules))
272        .route("/api/schedules", post(git_webhook::api_create_schedule))
273        .route(
274            "/api/schedules",
275            axum::routing::delete(git_webhook::api_delete_schedule),
276        )
277        .route_layer(middleware::from_fn_with_state(
278            state.clone(),
279            require_api_key,
280        ));
281
282    let app = protected
283        .route("/healthz", get(healthz))
284        .route("/badge/{metric}", get(badge_handler))
285        .route("/static/chart.js", get(chart_js_handler))
286        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
287        .route("/webhooks/github", post(git_webhook::handle_github_webhook))
288        .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
289        .route(
290            "/webhooks/bitbucket",
291            post(git_webhook::handle_bitbucket_webhook),
292        )
293        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
294        .layer(middleware::from_fn_with_state(
295            state.clone(),
296            add_security_headers,
297        ))
298        .layer(build_cors_layer(state.server_mode))
299        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
300        .with_state(state.clone());
301
302    // Try the configured port first, then step up through a few alternatives.
303    // On Windows, a killed process can leave its LISTEN socket as an unkillable
304    // kernel zombie (visible in netstat but owned by no living process).  Rather
305    // than failing, we auto-select the next free port and tell the user.
306    let preferred: SocketAddr = bind_address
307        .parse()
308        .with_context(|| format!("invalid bind address: {bind_address}"))?;
309    let (listener, addr) = {
310        let candidates = (0u16..=9).map(|offset| {
311            let mut a = preferred;
312            a.set_port(preferred.port().saturating_add(offset));
313            a
314        });
315        let mut found = None;
316        for candidate in candidates {
317            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
318                found = Some((l, candidate));
319                break;
320            }
321        }
322        found.ok_or_else(|| {
323            anyhow::anyhow!(
324                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
325                bind_address,
326                preferred.port(),
327                preferred.port().saturating_add(9)
328            )
329        })?
330    };
331    if addr != preferred {
332        eprintln!(
333            "NOTE: port {} is blocked by a system socket (Windows zombie); \
334             using {} instead.",
335            preferred.port(),
336            addr.port()
337        );
338    }
339
340    if tls_enabled {
341        let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
342        let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
343        let tls_config = build_tls_config(&cert_path, &key_path)
344            .context("failed to load TLS certificate/key")?;
345        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
346
347        let url = format!("https://{addr}/");
348        println!("OxideSLOC server running at {url} (TLS)");
349        println!("Use Ctrl+C to stop.");
350
351        return serve_tls(listener, app, acceptor, server_mode).await;
352    }
353
354    let url = format!("http://{addr}/");
355    log_startup_url(&url, server_mode);
356
357    axum::serve(
358        listener,
359        app.into_make_service_with_connect_info::<SocketAddr>(),
360    )
361    .with_graceful_shutdown(shutdown_signal(server_mode))
362    .await
363    .context("web server terminated unexpectedly")
364}
365
366/// Print the startup URL and, in local mode, open the browser and schedule it.
367fn log_startup_url(url: &str, server_mode: bool) {
368    if server_mode {
369        println!("OxideSLOC server running at {url}");
370        println!("Use Ctrl+C to stop.");
371    } else {
372        println!("OxideSLOC local web UI running at {url}");
373        println!("Press Ctrl+C to stop the server.");
374        let open_url = url.to_owned();
375        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
376    }
377}
378
379/// Open the given URL in the default system browser.
380fn open_browser_tab(url: &str) {
381    #[cfg(target_os = "windows")]
382    let _ = std::process::Command::new("cmd")
383        .args(["/c", "start", "", url])
384        .stdout(Stdio::null())
385        .stderr(Stdio::null())
386        .spawn();
387    #[cfg(target_os = "macos")]
388    let _ = std::process::Command::new("open")
389        .arg(url)
390        .stdout(Stdio::null())
391        .stderr(Stdio::null())
392        .spawn();
393    #[cfg(target_os = "linux")]
394    let _ = std::process::Command::new("xdg-open")
395        .arg(url)
396        .stdout(Stdio::null())
397        .stderr(Stdio::null())
398        .spawn();
399}
400
401/// Graceful-shutdown future: resolves on Ctrl-C.
402async fn shutdown_signal(server_mode: bool) {
403    if tokio::signal::ctrl_c().await.is_ok() {
404        println!();
405        if server_mode {
406            println!("Shutting down OxideSLOC server...");
407        } else {
408            println!("Shutting down OxideSLOC local web UI...");
409        }
410        println!("Server stopped cleanly.");
411    }
412}
413
414/// Load a rustls `ServerConfig` from PEM certificate and key files.
415fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
416    use rustls_pemfile::{certs, private_key};
417    use std::io::BufReader;
418
419    let cert_bytes =
420        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
421    let key_bytes =
422        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
423
424    let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
425        .collect::<std::result::Result<_, _>>()
426        .context("failed to parse TLS certificates")?;
427
428    let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
429        .context("failed to parse TLS private key")?
430        .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
431
432    rustls::ServerConfig::builder()
433        .with_no_client_auth()
434        .with_single_cert(cert_chain, key)
435        .context("failed to build TLS server config")
436}
437
438/// Accept loop with TLS termination using tokio-rustls + hyper-util.
439async fn serve_tls(
440    listener: tokio::net::TcpListener,
441    app: Router,
442    acceptor: tokio_rustls::TlsAcceptor,
443    server_mode: bool,
444) -> Result<()> {
445    use hyper_util::rt::{TokioExecutor, TokioIo};
446    use hyper_util::server::conn::auto::Builder as ConnBuilder;
447    use hyper_util::service::TowerToHyperService;
448    use tower::{Service, ServiceExt};
449
450    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
451
452    loop {
453        tokio::select! {
454            biased;
455            _ = tokio::signal::ctrl_c() => {
456                println!();
457                if server_mode {
458                    println!("Shutting down OxideSLOC server...");
459                } else {
460                    println!("Shutting down OxideSLOC local web UI...");
461                }
462                println!("Server stopped cleanly.");
463                return Ok(());
464            }
465            result = listener.accept() => {
466                let (tcp, peer_addr) = result.context("TLS accept failed")?;
467                let acceptor = acceptor.clone();
468                let mut factory = make_svc.clone();
469
470                tokio::spawn(async move {
471                    let tls = match acceptor.accept(tcp).await {
472                        Ok(s) => s,
473                        Err(e) => {
474                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
475                            return;
476                        }
477                    };
478                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
479                        Ok(f) => match Service::call(f, peer_addr).await {
480                            Ok(s) => s,
481                            Err(_) => return,
482                        },
483                        Err(_) => return,
484                    };
485                    let io = TokioIo::new(tls);
486                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
487                        .serve_connection(io, TowerToHyperService::new(svc))
488                        .await
489                    {
490                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
491                    }
492                });
493            }
494        }
495    }
496}
497
498async fn require_api_key(
499    State(state): State<AppState>,
500    req: Request<Body>,
501    next: Next,
502) -> Response {
503    if !state.api_keys.is_empty() {
504        let keys = &state.api_keys;
505        let provided = req
506            .headers()
507            .get(header::AUTHORIZATION)
508            .and_then(|v| v.to_str().ok())
509            .and_then(|v| v.strip_prefix("Bearer "))
510            .or_else(|| req.headers().get("X-API-Key").and_then(|v| v.to_str().ok()));
511
512        let peer_ip = req
513            .extensions()
514            .get::<axum::extract::ConnectInfo<SocketAddr>>()
515            .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
516
517        if state.rate_limiter.is_auth_locked_out(peer_ip) {
518            tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
519                "Authentication locked out after repeated failures");
520            return (
521                StatusCode::TOO_MANY_REQUESTS,
522                [(header::RETRY_AFTER, "3600")],
523                "429 Too Many Requests — authentication temporarily locked\n",
524            )
525                .into_response();
526        }
527
528        if provided.is_some_and(|k| {
529            keys.iter().any(|expected| {
530                use secrecy::ExposeSecret;
531                ct_eq(k, expected.expose_secret())
532            })
533        }) {
534            return next.run(req).await;
535        }
536
537        state.rate_limiter.record_auth_failure(peer_ip);
538        let path = req.uri().path().to_owned();
539        tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
540            "API key authentication failed");
541        return (
542            StatusCode::UNAUTHORIZED,
543            [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
544            "401 Unauthorized\n",
545        )
546            .into_response();
547    }
548    next.run(req).await
549}
550
551fn ct_eq(a: &str, b: &str) -> bool {
552    use subtle::ConstantTimeEq;
553    a.as_bytes().ct_eq(b.as_bytes()).into()
554}
555
556fn build_cors_layer(server_mode: bool) -> CorsLayer {
557    if server_mode {
558        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
559            .unwrap_or_default()
560            .split(',')
561            .filter(|s| !s.is_empty())
562            .filter_map(|s| s.trim().parse().ok())
563            .collect();
564        if allowed.is_empty() {
565            return CorsLayer::new();
566        }
567        CorsLayer::new()
568            .allow_origin(AllowOrigin::list(allowed))
569            .allow_methods(AllowMethods::list([
570                axum::http::Method::GET,
571                axum::http::Method::POST,
572            ]))
573            .allow_headers(AllowHeaders::list([
574                axum::http::header::AUTHORIZATION,
575                axum::http::header::CONTENT_TYPE,
576            ]))
577    } else {
578        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
579            let s = origin.to_str().unwrap_or("");
580            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
581        }))
582    }
583}
584
585async fn add_security_headers(
586    State(state): State<AppState>,
587    mut req: Request<Body>,
588    next: Next,
589) -> Response {
590    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
591    req.extensions_mut().insert(CspNonce(nonce.clone()));
592    let mut resp = next.run(req).await;
593    let h = resp.headers_mut();
594    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
595    h.insert(
596        "X-Content-Type-Options",
597        HeaderValue::from_static("nosniff"),
598    );
599    h.insert(
600        "Referrer-Policy",
601        HeaderValue::from_static("strict-origin-when-cross-origin"),
602    );
603    let csp = format!(
604        "default-src 'self'; \
605         style-src 'self' 'nonce-{nonce}'; \
606         img-src 'self' data: blob:; \
607         script-src 'self' 'nonce-{nonce}'; \
608         font-src 'self' data:; \
609         object-src 'none'; \
610         frame-ancestors 'none'"
611    );
612    h.insert(
613        "Content-Security-Policy",
614        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
615            HeaderValue::from_static(
616                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
617            )
618        }),
619    );
620    h.insert(
621        "X-Permitted-Cross-Domain-Policies",
622        HeaderValue::from_static("none"),
623    );
624    h.insert(
625        "Permissions-Policy",
626        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
627    );
628    h.insert(
629        "Cross-Origin-Opener-Policy",
630        HeaderValue::from_static("same-origin"),
631    );
632    h.insert(
633        "Cross-Origin-Resource-Policy",
634        HeaderValue::from_static("same-origin"),
635    );
636    if state.tls_enabled {
637        h.insert(
638            "Strict-Transport-Security",
639            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
640        );
641    }
642    resp
643}
644
645async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
646    let ip = req
647        .extensions()
648        .get::<axum::extract::ConnectInfo<SocketAddr>>()
649        .map(|c| c.0.ip())
650        .or_else(|| {
651            if state.trust_proxy {
652                req.headers()
653                    .get("X-Forwarded-For")
654                    .and_then(|v| v.to_str().ok())
655                    .and_then(|s| s.split(',').next())
656                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
657            } else {
658                None
659            }
660        })
661        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
662
663    if !state.rate_limiter.is_allowed(ip) {
664        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
665            path = %req.uri().path(), "Rate limit exceeded");
666        return (
667            StatusCode::TOO_MANY_REQUESTS,
668            [(header::RETRY_AFTER, "60")],
669            "429 Too Many Requests\n",
670        )
671            .into_response();
672    }
673    next.run(req).await
674}
675
676async fn splash(
677    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
678) -> impl IntoResponse {
679    let template = SplashTemplate { csp_nonce };
680    Html(
681        template
682            .render()
683            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
684    )
685}
686
687async fn index(
688    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
689    Query(query): Query<IndexQuery>,
690) -> impl IntoResponse {
691    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
692        let policy = query
693            .mixed_line_policy
694            .unwrap_or_else(|| "code_only".to_string());
695        let behavior = query
696            .binary_file_behavior
697            .unwrap_or_else(|| "skip".to_string());
698        let cfg = ScanConfig {
699            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
700            path: query.path.unwrap_or_default(),
701            include_globs: query.include_globs.unwrap_or_default(),
702            exclude_globs: query.exclude_globs.unwrap_or_default(),
703            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
704            mixed_line_policy: policy,
705            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
706                != Some("off"),
707            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
708            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
709            vendor_directory_detection: query.vendor_directory_detection.as_deref()
710                != Some("disabled"),
711            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
712            binary_file_behavior: behavior,
713            output_dir: query.output_dir.unwrap_or_default(),
714            report_title: query.report_title.unwrap_or_default(),
715            generate_html: query.generate_html.as_deref() != Some("off"),
716            generate_pdf: query.generate_pdf.as_deref() == Some("on"),
717        };
718        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
719    } else {
720        "{}".to_string()
721    };
722
723    let template = IndexTemplate {
724        version: env!("CARGO_PKG_VERSION"),
725        prefill_json,
726        csp_nonce,
727    };
728
729    Html(
730        template
731            .render()
732            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
733    )
734}
735
736async fn scan_setup_handler(
737    State(state): State<AppState>,
738    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
739) -> impl IntoResponse {
740    let recent_scans_json = {
741        let arr: Vec<serde_json::Value> = {
742            let reg = state.registry.lock().await;
743            reg.entries
744                .iter()
745                .rev()
746                .take(6)
747                .map(|e| {
748                    let run_dir = e
749                        .html_path
750                        .as_ref()
751                        .or(e.json_path.as_ref())
752                        .and_then(|p| p.parent().map(PathBuf::from));
753                    let config_val: Option<serde_json::Value> = run_dir
754                        .map(|d| d.join("scan-config.json"))
755                        .filter(|p| p.exists())
756                        .and_then(|p| fs::read_to_string(&p).ok())
757                        .and_then(|s| serde_json::from_str(&s).ok());
758                    serde_json::json!({
759                        "project_label": e.project_label,
760                        "timestamp": fmt_pst(e.timestamp_utc),
761                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
762                        "config": config_val,
763                    })
764                })
765                .collect()
766        };
767        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
768    };
769
770    let template = ScanSetupTemplate {
771        recent_scans_json,
772        csp_nonce,
773    };
774    Html(
775        template
776            .render()
777            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
778    )
779}
780
781async fn healthz() -> &'static str {
782    "ok"
783}
784
785async fn chart_js_handler() -> impl IntoResponse {
786    (
787        [(
788            header::CONTENT_TYPE,
789            "application/javascript; charset=utf-8",
790        )],
791        CHART_JS,
792    )
793}
794
795#[derive(Debug, Deserialize)]
796struct AnalyzeForm {
797    path: String,
798    mixed_line_policy: Option<MixedLinePolicy>,
799    python_docstrings_as_comments: Option<String>,
800    generated_file_detection: Option<String>,
801    minified_file_detection: Option<String>,
802    vendor_directory_detection: Option<String>,
803    include_lockfiles: Option<String>,
804    binary_file_behavior: Option<BinaryFileBehavior>,
805    output_dir: Option<String>,
806    report_title: Option<String>,
807    generate_html: Option<String>,
808    generate_pdf: Option<String>,
809    include_globs: Option<String>,
810    exclude_globs: Option<String>,
811    submodule_breakdown: Option<String>,
812}
813
814#[allow(clippy::struct_excessive_bools)]
815#[derive(Debug, Serialize, Deserialize, Clone)]
816struct ScanConfig {
817    oxide_sloc_version: String,
818    path: String,
819    include_globs: String,
820    exclude_globs: String,
821    submodule_breakdown: bool,
822    mixed_line_policy: String,
823    python_docstrings_as_comments: bool,
824    generated_file_detection: bool,
825    minified_file_detection: bool,
826    vendor_directory_detection: bool,
827    include_lockfiles: bool,
828    binary_file_behavior: String,
829    output_dir: String,
830    report_title: String,
831    generate_html: bool,
832    generate_pdf: bool,
833}
834
835#[derive(Debug, Deserialize, Default)]
836struct IndexQuery {
837    path: Option<String>,
838    include_globs: Option<String>,
839    exclude_globs: Option<String>,
840    submodule_breakdown: Option<String>,
841    mixed_line_policy: Option<String>,
842    python_docstrings_as_comments: Option<String>,
843    generated_file_detection: Option<String>,
844    minified_file_detection: Option<String>,
845    vendor_directory_detection: Option<String>,
846    include_lockfiles: Option<String>,
847    binary_file_behavior: Option<String>,
848    output_dir: Option<String>,
849    report_title: Option<String>,
850    generate_html: Option<String>,
851    generate_pdf: Option<String>,
852    prefilled: Option<String>,
853}
854
855#[derive(Debug, Deserialize)]
856struct PreviewQuery {
857    path: Option<String>,
858    include_globs: Option<String>,
859    exclude_globs: Option<String>,
860}
861
862#[cfg(feature = "native-dialog")]
863#[derive(Debug, Deserialize)]
864struct PickDirectoryQuery {
865    kind: Option<String>,
866    current: Option<String>,
867}
868
869#[cfg(not(feature = "native-dialog"))]
870#[derive(Debug, Deserialize)]
871struct PickDirectoryQuery {}
872
873#[derive(Debug, Deserialize, Default)]
874struct ArtifactQuery {
875    download: Option<String>,
876}
877
878#[cfg(feature = "native-dialog")]
879#[derive(Debug, Serialize)]
880struct PickDirectoryResponse {
881    selected_path: Option<String>,
882    cancelled: bool,
883}
884
885#[cfg(feature = "native-dialog")]
886async fn pick_directory_handler(
887    State(state): State<AppState>,
888    Query(query): Query<PickDirectoryQuery>,
889) -> Response {
890    if state.server_mode {
891        return StatusCode::NOT_FOUND.into_response();
892    }
893
894    let title = match query.kind.as_deref() {
895        Some("output") => "Select output directory",
896        _ => "Select project directory",
897    };
898
899    let mut dialog = rfd::FileDialog::new().set_title(title);
900    if let Some(current) = query.current.as_deref() {
901        let resolved = resolve_input_path(current);
902        let seed = if resolved.is_dir() {
903            Some(resolved)
904        } else {
905            resolved.parent().map(Path::to_path_buf)
906        };
907        if let Some(seed_dir) = seed.filter(|p| p.exists()) {
908            dialog = dialog.set_directory(seed_dir);
909        }
910    }
911
912    let picked = dialog.pick_folder();
913
914    Json(PickDirectoryResponse {
915        selected_path: picked.as_ref().map(|p| display_path(p)),
916        cancelled: picked.is_none(),
917    })
918    .into_response()
919}
920
921#[cfg(not(feature = "native-dialog"))]
922async fn pick_directory_handler(
923    State(_state): State<AppState>,
924    Query(_query): Query<PickDirectoryQuery>,
925) -> Response {
926    StatusCode::NOT_FOUND.into_response()
927}
928
929#[cfg(feature = "native-dialog")]
930async fn pick_file_handler(State(state): State<AppState>) -> Response {
931    if state.server_mode {
932        return StatusCode::NOT_FOUND.into_response();
933    }
934    let picked = rfd::FileDialog::new()
935        .set_title("Select HTML report")
936        .add_filter("HTML report", &["html"])
937        .pick_file();
938    Json(PickDirectoryResponse {
939        selected_path: picked.as_ref().map(|p| display_path(p)),
940        cancelled: picked.is_none(),
941    })
942    .into_response()
943}
944
945#[cfg(not(feature = "native-dialog"))]
946async fn pick_file_handler(State(_state): State<AppState>) -> Response {
947    StatusCode::NOT_FOUND.into_response()
948}
949
950#[derive(Deserialize)]
951struct LocateReportForm {
952    file_path: String,
953}
954
955/// Render a view-reports error page and return it as a `Response`.
956fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
957    let html = ErrorTemplate {
958        message: message.into(),
959        last_report_url: Some("/view-reports".to_string()),
960        last_report_label: Some("View Reports".to_string()),
961        csp_nonce: csp_nonce.to_owned(),
962    }
963    .render()
964    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
965    Html(html).into_response()
966}
967
968/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
969fn registry_entry_from_run(
970    run: &AnalysisRun,
971    json_path: PathBuf,
972    html_path: PathBuf,
973) -> RegistryEntry {
974    let project_label = run.input_roots.first().map_or_else(
975        || "Unknown Project".to_string(),
976        |r| sanitize_project_label(r),
977    );
978    RegistryEntry {
979        run_id: run.tool.run_id.clone(),
980        timestamp_utc: run.tool.timestamp_utc,
981        project_label,
982        input_roots: run.input_roots.clone(),
983        json_path: Some(json_path),
984        html_path: Some(html_path),
985        pdf_path: None,
986        summary: ScanSummarySnapshot {
987            files_analyzed: run.summary_totals.files_analyzed,
988            files_skipped: run.summary_totals.files_skipped,
989            total_physical_lines: run.summary_totals.total_physical_lines,
990            code_lines: run.summary_totals.code_lines,
991            comment_lines: run.summary_totals.comment_lines,
992            blank_lines: run.summary_totals.blank_lines,
993            functions: run.summary_totals.functions,
994            classes: run.summary_totals.classes,
995            variables: run.summary_totals.variables,
996            imports: run.summary_totals.imports,
997        },
998        git_branch: None,
999        git_commit: None,
1000        git_author: None,
1001        git_tags: None,
1002    }
1003}
1004
1005/// Validate the locate-report form: check extension, resolve the canonical path, enforce
1006/// server-mode root restriction, and extract the parent directory.
1007///
1008/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
1009#[allow(clippy::result_large_err)]
1010fn validate_locate_request(
1011    state: &AppState,
1012    file_path: &str,
1013    csp_nonce: &str,
1014) -> Result<(PathBuf, PathBuf), Response> {
1015    let file_ext = Path::new(file_path)
1016        .extension()
1017        .and_then(|e| e.to_str())
1018        .unwrap_or("")
1019        .to_ascii_lowercase();
1020    if file_ext != "html" {
1021        return Err(locate_report_error(
1022            "Only .html report files can be located via this form.",
1023            csp_nonce,
1024        ));
1025    }
1026    let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1027        Ok(p) => strip_unc_prefix(p),
1028        Err(_) => {
1029            return Err(locate_report_error(
1030                "Report file not found or path is invalid.",
1031                csp_nonce,
1032            ));
1033        }
1034    };
1035    if state.server_mode {
1036        let output_root = resolve_output_root(None);
1037        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1038        if !html_path.starts_with(&canonical_root) {
1039            return Err(locate_report_error(
1040                "Report file must be within the configured output directory.",
1041                csp_nonce,
1042            ));
1043        }
1044    }
1045    let parent = match html_path.parent() {
1046        Some(p) => p.to_path_buf(),
1047        None => {
1048            return Err(locate_report_error(
1049                "Report file has no parent directory.",
1050                csp_nonce,
1051            ));
1052        }
1053    };
1054    Ok((html_path, parent))
1055}
1056
1057/// Return a non-sensitive path hint for error messages (empty in server mode).
1058fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1059    if server_mode {
1060        String::new()
1061    } else {
1062        format!("\n\nFile: {}", path.display())
1063    }
1064}
1065
1066async fn locate_report_handler(
1067    State(state): State<AppState>,
1068    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1069    Form(form): Form<LocateReportForm>,
1070) -> impl IntoResponse {
1071    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1072        Ok(v) => v,
1073        Err(resp) => return resp,
1074    };
1075
1076    let json_candidate = parent.join("result.json");
1077    let mut reg = state.registry.lock().await;
1078    // Find an existing entry whose output directory matches the selected file's parent.
1079    let entry_idx = reg.entries.iter().position(|e| {
1080        let json_match = e
1081            .json_path
1082            .as_ref()
1083            .and_then(|p| p.parent())
1084            .is_some_and(|p| p == parent);
1085        let html_match = e
1086            .html_path
1087            .as_ref()
1088            .and_then(|p| p.parent())
1089            .is_some_and(|p| p == parent);
1090        json_match || html_match
1091    });
1092    if let Some(idx) = entry_idx {
1093        reg.entries[idx].html_path = Some(html_path);
1094        let _ = reg.save(&state.registry_path);
1095        return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1096    }
1097    // No match — attempt to build an entry from an adjacent result.json.
1098    if json_candidate.exists() {
1099        match read_json(&json_candidate) {
1100            Ok(run) => {
1101                let entry = registry_entry_from_run(&run, json_candidate, html_path);
1102                reg.add_entry(entry);
1103                let _ = reg.save(&state.registry_path);
1104                return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1105            }
1106            Err(e) => {
1107                let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1108                let err_detail = if state.server_mode {
1109                    String::new()
1110                } else {
1111                    format!("\n\nError: {e}")
1112                };
1113                return locate_report_error(
1114                    format!(
1115                        "Could not link this report.\n\nA 'result.json' was found but could not \
1116                         be parsed — it may have been saved by an older version of OxideSLOC. \
1117                         Re-running the analysis will create a fresh, compatible \
1118                         record.{file_hint}{err_detail}"
1119                    ),
1120                    &csp_nonce,
1121                );
1122            }
1123        }
1124    }
1125    drop(reg);
1126    let file_hint = locate_path_hint(state.server_mode, &html_path);
1127    locate_report_error(
1128        format!(
1129            "Could not link this report.\n\nNo matching scan record was found, and no \
1130             'result.json' was found in the same folder.{file_hint}"
1131        ),
1132        &csp_nonce,
1133    )
1134}
1135
1136#[derive(Debug, Deserialize)]
1137struct OpenPathQuery {
1138    path: Option<String>,
1139}
1140
1141async fn open_path_handler(
1142    State(state): State<AppState>,
1143    Query(query): Query<OpenPathQuery>,
1144) -> impl IntoResponse {
1145    if state.server_mode {
1146        return StatusCode::NOT_FOUND.into_response();
1147    }
1148    let raw = match query.path.as_deref() {
1149        Some(p) if !p.is_empty() => p,
1150        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
1151    };
1152
1153    let Ok(canonical) = fs::canonicalize(raw) else {
1154        return (StatusCode::BAD_REQUEST, "path not found").into_response();
1155    };
1156
1157    // Must be a directory (or a file whose parent directory we open).
1158    let target = if canonical.is_file() {
1159        match canonical.parent() {
1160            Some(p) => p.to_path_buf(),
1161            None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
1162        }
1163    } else if canonical.is_dir() {
1164        canonical
1165    } else {
1166        // Block special devices, pipes, sockets, etc.
1167        return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response();
1168    };
1169
1170    #[cfg(target_os = "windows")]
1171    let _ = std::process::Command::new("explorer.exe")
1172        .arg(&target)
1173        .stdout(Stdio::null())
1174        .stderr(Stdio::null())
1175        .spawn();
1176    #[cfg(target_os = "macos")]
1177    let _ = std::process::Command::new("open")
1178        .arg(&target)
1179        .stdout(Stdio::null())
1180        .stderr(Stdio::null())
1181        .spawn();
1182    #[cfg(target_os = "linux")]
1183    let _ = std::process::Command::new("xdg-open")
1184        .arg(&target)
1185        .stdout(Stdio::null())
1186        .stderr(Stdio::null())
1187        .spawn();
1188
1189    (StatusCode::OK, "ok").into_response()
1190}
1191
1192async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
1193    let safe_folder = match folder.as_str() {
1194        "icons" | "logo" => folder,
1195        _ => return StatusCode::NOT_FOUND.into_response(),
1196    };
1197
1198    let safe_name = Path::new(&file)
1199        .file_name()
1200        .and_then(|name| name.to_str())
1201        .unwrap_or("");
1202
1203    if safe_name.is_empty() {
1204        return StatusCode::NOT_FOUND.into_response();
1205    }
1206
1207    let ext = Path::new(safe_name)
1208        .extension()
1209        .and_then(|e| e.to_str())
1210        .unwrap_or("")
1211        .to_ascii_lowercase();
1212
1213    let content_type = match ext.as_str() {
1214        "png" => "image/png",
1215        "jpg" | "jpeg" => "image/jpeg",
1216        "webp" => "image/webp",
1217        "svg" => "image/svg+xml",
1218        _ => return StatusCode::NOT_FOUND.into_response(),
1219    };
1220
1221    let path = workspace_root()
1222        .join("docs")
1223        .join("assets")
1224        .join(safe_folder)
1225        .join(safe_name);
1226    fs::read(path).map_or_else(
1227        |_| StatusCode::NOT_FOUND.into_response(),
1228        |bytes| ([(header::CONTENT_TYPE, content_type)], bytes).into_response(),
1229    )
1230}
1231
1232async fn preview_handler(
1233    State(state): State<AppState>,
1234    Query(query): Query<PreviewQuery>,
1235) -> impl IntoResponse {
1236    let raw_path = query
1237        .path
1238        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
1239    let resolved = resolve_input_path(&raw_path);
1240
1241    if state.server_mode {
1242        let config = &state.base_config;
1243        if config.discovery.allowed_scan_roots.is_empty() {
1244            return Html(
1245                r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
1246            );
1247        }
1248        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
1249        let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1250            fs::canonicalize(root)
1251                .ok()
1252                .is_some_and(|r| canonical.starts_with(&r))
1253        });
1254        if !allowed {
1255            return Html(
1256                r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
1257            );
1258        }
1259    }
1260
1261    let include_patterns = split_patterns(query.include_globs.as_deref());
1262    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
1263
1264    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
1265        Ok(html) => Html(html),
1266        Err(err) => Html(format!(
1267            r#"<div class="preview-error">Preview failed: {}</div>"#,
1268            escape_html(&err.to_string())
1269        )),
1270    }
1271}
1272
1273/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
1274#[allow(clippy::result_large_err)]
1275fn validate_server_scan_path(
1276    config: &sloc_config::AppConfig,
1277    resolved_path: &Path,
1278    csp_nonce: &str,
1279) -> Result<(), Response> {
1280    if config.discovery.allowed_scan_roots.is_empty() {
1281        let template = ErrorTemplate {
1282            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
1283                      Set allowed_scan_roots in the server config to permit scanning."
1284                .to_string(),
1285            last_report_url: None,
1286            last_report_label: None,
1287            csp_nonce: csp_nonce.to_owned(),
1288        };
1289        return Err((
1290            StatusCode::FORBIDDEN,
1291            Html(
1292                template
1293                    .render()
1294                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
1295            ),
1296        )
1297            .into_response());
1298    }
1299    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
1300    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1301        fs::canonicalize(root)
1302            .ok()
1303            .is_some_and(|r| canonical.starts_with(&r))
1304    });
1305    if !allowed {
1306        tracing::warn!(event = "path_rejected", path = %canonical.display(),
1307            "Scan path not in allowed_scan_roots");
1308        let template = ErrorTemplate {
1309            message: "The requested path is not within an allowed scan directory.".to_string(),
1310            last_report_url: None,
1311            last_report_label: None,
1312            csp_nonce: csp_nonce.to_owned(),
1313        };
1314        return Err((
1315            StatusCode::FORBIDDEN,
1316            Html(
1317                template
1318                    .render()
1319                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
1320            ),
1321        )
1322            .into_response());
1323    }
1324    Ok(())
1325}
1326
1327/// Exclude the output directory from scanning so artifacts don't pollute counts.
1328fn apply_output_dir_exclusions(
1329    config: &mut sloc_config::AppConfig,
1330    project_path: &str,
1331    raw_output_dir: &str,
1332) {
1333    let project_root = resolve_input_path(project_path);
1334    let raw_out = raw_output_dir.trim();
1335    let resolved_out = if raw_out.is_empty() {
1336        project_root.join("sloc")
1337    } else if Path::new(raw_out).is_absolute() {
1338        PathBuf::from(raw_out)
1339    } else {
1340        workspace_root().join(raw_out)
1341    };
1342    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
1343        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
1344            let dir = first.to_string();
1345            if !config.discovery.excluded_directories.contains(&dir) {
1346                config.discovery.excluded_directories.push(dir);
1347            }
1348        }
1349    }
1350    if !config
1351        .discovery
1352        .excluded_directories
1353        .iter()
1354        .any(|d| d == "sloc")
1355    {
1356        config
1357            .discovery
1358            .excluded_directories
1359            .push("sloc".to_string());
1360    }
1361}
1362
1363/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
1364const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
1365    ScanSummarySnapshot {
1366        files_analyzed: run.summary_totals.files_analyzed,
1367        files_skipped: run.summary_totals.files_skipped,
1368        total_physical_lines: run.summary_totals.total_physical_lines,
1369        code_lines: run.summary_totals.code_lines,
1370        comment_lines: run.summary_totals.comment_lines,
1371        blank_lines: run.summary_totals.blank_lines,
1372        functions: run.summary_totals.functions,
1373        classes: run.summary_totals.classes,
1374        variables: run.summary_totals.variables,
1375        imports: run.summary_totals.imports,
1376    }
1377}
1378
1379/// Build the `RegistryEntry` for the just-completed scan run.
1380fn build_run_registry_entry(
1381    run: &AnalysisRun,
1382    run_id: &str,
1383    project_label: &str,
1384    artifacts: &RunArtifacts,
1385) -> RegistryEntry {
1386    RegistryEntry {
1387        run_id: run_id.to_owned(),
1388        timestamp_utc: run.tool.timestamp_utc,
1389        project_label: project_label.to_owned(),
1390        input_roots: run.input_roots.clone(),
1391        json_path: artifacts.json_path.clone(),
1392        html_path: artifacts.html_path.clone(),
1393        pdf_path: artifacts.pdf_path.clone(),
1394        summary: summary_snapshot_from_run(run),
1395        git_branch: run.git_branch.clone(),
1396        git_commit: run.git_commit_short.clone(),
1397        git_author: run.git_commit_author.clone(),
1398        git_tags: run.git_tags.clone(),
1399    }
1400}
1401
1402/// Serialize form settings into a `ScanConfig` for `scan-config.json`.
1403fn build_scan_config_from_form(form: &AnalyzeForm, run: &AnalysisRun) -> ScanConfig {
1404    let policy_str = serde_json::to_value(form.mixed_line_policy)
1405        .ok()
1406        .filter(|v| !v.is_null())
1407        .and_then(|v| v.as_str().map(String::from))
1408        .unwrap_or_else(|| "code_only".to_string());
1409    let behavior_str = serde_json::to_value(form.binary_file_behavior)
1410        .ok()
1411        .filter(|v| !v.is_null())
1412        .and_then(|v| v.as_str().map(String::from))
1413        .unwrap_or_else(|| "skip".to_string());
1414    ScanConfig {
1415        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1416        path: form.path.clone(),
1417        include_globs: form.include_globs.clone().unwrap_or_default(),
1418        exclude_globs: form.exclude_globs.clone().unwrap_or_default(),
1419        submodule_breakdown: form.submodule_breakdown.as_deref() == Some("enabled"),
1420        mixed_line_policy: policy_str,
1421        python_docstrings_as_comments: form.python_docstrings_as_comments.is_some(),
1422        generated_file_detection: form.generated_file_detection.as_deref() != Some("disabled"),
1423        minified_file_detection: form.minified_file_detection.as_deref() != Some("disabled"),
1424        vendor_directory_detection: form.vendor_directory_detection.as_deref() != Some("disabled"),
1425        include_lockfiles: form.include_lockfiles.as_deref() == Some("enabled"),
1426        binary_file_behavior: behavior_str,
1427        output_dir: form.output_dir.clone().unwrap_or_default(),
1428        report_title: run.effective_configuration.reporting.report_title.clone(),
1429        generate_html: form.generate_html.is_some(),
1430        generate_pdf: form.generate_pdf.is_some(),
1431    }
1432}
1433
1434/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
1435fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
1436    if let Some(policy) = form.mixed_line_policy {
1437        config.analysis.mixed_line_policy = policy;
1438    }
1439    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
1440    config.analysis.generated_file_detection =
1441        form.generated_file_detection.as_deref() != Some("disabled");
1442    config.analysis.minified_file_detection =
1443        form.minified_file_detection.as_deref() != Some("disabled");
1444    config.analysis.vendor_directory_detection =
1445        form.vendor_directory_detection.as_deref() != Some("disabled");
1446    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
1447    if let Some(binary_behavior) = form.binary_file_behavior {
1448        config.analysis.binary_file_behavior = binary_behavior;
1449    }
1450    if let Some(report_title) = form.report_title.as_deref() {
1451        let trimmed = report_title.trim();
1452        if !trimmed.is_empty() {
1453            config.reporting.report_title = trimmed.to_string();
1454        }
1455    }
1456    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
1457    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
1458    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
1459}
1460
1461/// Fire-and-forget: generate the PDF in a background task if one is pending.
1462fn spawn_pdf_background(pending_pdf: PendingPdf) {
1463    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
1464        tokio::spawn(async move {
1465            let result = tokio::task::spawn_blocking(move || {
1466                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
1467                if cleanup_src {
1468                    let _ = fs::remove_file(&pdf_src);
1469                }
1470                r
1471            })
1472            .await;
1473            match result {
1474                Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
1475                Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
1476                Ok(Ok(())) => {}
1477            }
1478        });
1479    }
1480}
1481
1482/// Sum the code lines added in this comparison (new + grown files).
1483fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
1484    cmp.file_deltas
1485        .iter()
1486        .map(|f| match f.status {
1487            FileChangeStatus::Added => f.current_code,
1488            FileChangeStatus::Modified => f.code_delta.max(0),
1489            _ => 0,
1490        })
1491        .sum()
1492}
1493
1494/// Sum the code lines removed in this comparison (deleted + shrunk files).
1495fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
1496    cmp.file_deltas
1497        .iter()
1498        .map(|f| match f.status {
1499            FileChangeStatus::Removed => f.baseline_code,
1500            FileChangeStatus::Modified => (-f.code_delta).max(0),
1501            _ => 0,
1502        })
1503        .sum()
1504}
1505
1506/// Build one `SubmoduleRow`, optionally generating and persisting a sub-report HTML file.
1507fn build_submodule_row(
1508    s: &sloc_core::SubmoduleSummary,
1509    run: &AnalysisRun,
1510    run_id: &str,
1511    run_dir: &Path,
1512    generate_html: bool,
1513) -> SubmoduleRow {
1514    let safe = sanitize_project_label(&s.name);
1515    let artifact_key = format!("sub_{safe}");
1516    let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
1517        let parent_path = run
1518            .input_roots
1519            .first()
1520            .map_or("", std::string::String::as_str);
1521        let sub_run = build_sub_run(run, s, parent_path);
1522        render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
1523            let path = run_dir.join(format!("{artifact_key}.html"));
1524            if fs::write(&path, sub_html.as_bytes()).is_ok() {
1525                Some(format!("/runs/{run_id}/{artifact_key}"))
1526            } else {
1527                None
1528            }
1529        })
1530    } else {
1531        None
1532    };
1533    SubmoduleRow {
1534        name: s.name.clone(),
1535        relative_path: s.relative_path.clone(),
1536        files_analyzed: s.files_analyzed,
1537        code_lines: s.code_lines,
1538        comment_lines: s.comment_lines,
1539        blank_lines: s.blank_lines,
1540        total_physical_lines: s.total_physical_lines,
1541        html_url,
1542    }
1543}
1544
1545// This Axum handler manages the full analysis lifecycle including config, execution, artifact
1546// writing, and response rendering; splitting it would require threading many borrowed values
1547// through async boundaries, complicating Send requirements.
1548#[allow(clippy::too_many_lines)]
1549#[allow(clippy::similar_names)]
1550async fn analyze_handler(
1551    State(state): State<AppState>,
1552    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1553    Form(form): Form<AnalyzeForm>,
1554) -> impl IntoResponse {
1555    let Ok(_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
1556        let template = ErrorTemplate {
1557            message: "Server is busy — too many concurrent analyses. Please try again in a moment."
1558                .to_string(),
1559            last_report_url: None,
1560            last_report_label: None,
1561            csp_nonce: csp_nonce.clone(),
1562        };
1563        return (
1564            StatusCode::SERVICE_UNAVAILABLE,
1565            Html(
1566                template
1567                    .render()
1568                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
1569            ),
1570        )
1571            .into_response();
1572    };
1573
1574    let mut config = state.base_config.clone();
1575    let resolved_path = resolve_input_path(&form.path);
1576
1577    if state.server_mode {
1578        if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
1579            return resp;
1580        }
1581    }
1582    config.discovery.root_paths = vec![resolved_path];
1583
1584    apply_form_to_config(&mut config, &form);
1585    // Auto-exclude the output directory so scan artifacts never appear in counts.
1586    apply_output_dir_exclusions(
1587        &mut config,
1588        &form.path,
1589        form.output_dir.as_deref().unwrap_or(""),
1590    );
1591
1592    let analysis_result =
1593        tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
1594            let run = analyze(&config, "serve")?;
1595            let html = render_html(&run)?;
1596            Ok((run, html))
1597        })
1598        .await
1599        .map_err(|err| anyhow::anyhow!(err.to_string()))
1600        .and_then(|result| result);
1601
1602    let (run, report_html) = match analysis_result {
1603        Ok(value) => value,
1604        Err(err) => {
1605            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
1606            let template = ErrorTemplate {
1607                message: "Analysis failed. Check that the path exists and is readable.".to_string(),
1608                last_report_url: None,
1609                last_report_label: None,
1610                csp_nonce: csp_nonce.clone(),
1611            };
1612            return Html(
1613                template
1614                    .render()
1615                    .unwrap_or_else(|_| "<pre>Analysis failed.</pre>".to_string()),
1616            )
1617            .into_response();
1618        }
1619    };
1620
1621    let run_id = run.tool.run_id.clone();
1622    tracing::info!(event = "scan_complete", run_id = %run_id,
1623        path = %form.path, files = run.summary_totals.files_analyzed, "Analysis finished");
1624
1625    // Capture the most-recent previous scan for this project before registering the current one.
1626    // Only consider entries whose json file still exists on disk.
1627    let prev_entry: Option<RegistryEntry> = {
1628        let reg = state.registry.lock().await;
1629        reg.entries_for_roots(&run.input_roots)
1630            .into_iter()
1631            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
1632            .cloned()
1633    };
1634
1635    // Git info is now captured inside analyze() and stored on the run.
1636    let git_branch = run.git_branch.clone();
1637    let git_commit = run.git_commit_short.clone();
1638    let git_author = run.git_commit_author.clone();
1639
1640    // Compute line-level delta vs the previous scan if JSON is available.
1641    let scan_delta = prev_entry.as_ref().and_then(|prev| {
1642        prev.json_path
1643            .as_ref()
1644            .and_then(|p| read_json(p).ok())
1645            .map(|prev_run| compute_delta(&prev_run, &run))
1646    });
1647    let prev_scan_count: usize = {
1648        let reg = state.registry.lock().await;
1649        reg.entries_for_roots(&run.input_roots)
1650            .iter()
1651            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
1652            .count()
1653    };
1654
1655    let output_root = resolve_output_root(form.output_dir.as_deref());
1656
1657    let project_label = sanitize_project_label(&form.path);
1658    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
1659
1660    let artifact_result = persist_run_artifacts(
1661        &run,
1662        &report_html,
1663        &run_dir,
1664        true, // JSON always generated so compare and diff are always available
1665        form.generate_html.is_some(),
1666        form.generate_pdf.is_some(),
1667        &run.effective_configuration.reporting.report_title,
1668    );
1669
1670    let (artifacts, pending_pdf) = match artifact_result {
1671        Ok(value) => value,
1672        Err(err) => {
1673            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
1674            let template = ErrorTemplate {
1675                message: "Failed to save report artifacts. Check available disk space.".to_string(),
1676                last_report_url: None,
1677                last_report_label: None,
1678                csp_nonce: csp_nonce.clone(),
1679            };
1680            return Html(
1681                template
1682                    .render()
1683                    .unwrap_or_else(|_| "<pre>Artifact write failed.</pre>".to_string()),
1684            )
1685            .into_response();
1686        }
1687    };
1688
1689    {
1690        let mut map = state.artifacts.lock().await;
1691        map.insert(run_id.clone(), artifacts.clone());
1692    }
1693
1694    // Persist entry to the on-disk registry.
1695    {
1696        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
1697        let mut reg = state.registry.lock().await;
1698        reg.add_entry(entry);
1699        let _ = reg.save(&state.registry_path);
1700    }
1701
1702    // Export scan-config.json alongside artifacts so users can reload settings later.
1703    {
1704        let scan_cfg = build_scan_config_from_form(&form, &run);
1705        if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
1706            let _ = fs::write(run_dir.join("scan-config.json"), json);
1707        }
1708    }
1709
1710    spawn_pdf_background(pending_pdf);
1711
1712    let language_rows = run
1713        .totals_by_language
1714        .iter()
1715        .map(|row| LanguageSummaryRow {
1716            language: row.language.display_name().to_string(),
1717            files: row.files,
1718            physical: row.total_physical_lines,
1719            code: row.code_lines,
1720            comments: row.comment_lines,
1721            blank: row.blank_lines,
1722            mixed: row.mixed_lines_separate,
1723            functions: row.functions,
1724            classes: row.classes,
1725            variables: row.variables,
1726            imports: row.imports,
1727        })
1728        .collect::<Vec<_>>();
1729
1730    let files_analyzed = run.per_file_records.len() as u64;
1731    let files_skipped = run.skipped_file_records.len() as u64;
1732    let physical_lines = language_rows.iter().map(|row| row.physical).sum::<u64>();
1733    let code_lines = language_rows.iter().map(|row| row.code).sum::<u64>();
1734    let comment_lines = language_rows.iter().map(|row| row.comments).sum::<u64>();
1735    let blank_lines = language_rows.iter().map(|row| row.blank).sum::<u64>();
1736    let mixed_lines = language_rows.iter().map(|row| row.mixed).sum::<u64>();
1737    let functions = language_rows.iter().map(|row| row.functions).sum::<u64>();
1738    let classes = language_rows.iter().map(|row| row.classes).sum::<u64>();
1739    let variables = language_rows.iter().map(|row| row.variables).sum::<u64>();
1740    let imports = language_rows.iter().map(|row| row.imports).sum::<u64>();
1741
1742    // Previous scan summary values for the metrics table Previous/Change columns.
1743    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
1744    let prev_fa = prev_sum.map(|s| s.files_analyzed);
1745    let prev_fs = prev_sum.map(|s| s.files_skipped);
1746    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
1747    let prev_cl = prev_sum.map(|s| s.code_lines);
1748    let prev_cml = prev_sum.map(|s| s.comment_lines);
1749    let prev_bl = prev_sum.map(|s| s.blank_lines);
1750    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
1751    let prev_fa_str = fmt_prev(prev_fa);
1752    let prev_fs_str = fmt_prev(prev_fs);
1753    let prev_pl_str = fmt_prev(prev_pl);
1754    let prev_cl_str = fmt_prev(prev_cl);
1755    let prev_cml_str = fmt_prev(prev_cml);
1756    let prev_bl_str = fmt_prev(prev_bl);
1757    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
1758    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
1759    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
1760    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
1761    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
1762    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
1763    let delta_fa_class = delta_fa_class.to_string();
1764    let delta_fs_class = delta_fs_class.to_string();
1765    let delta_pl_class = delta_pl_class.to_string();
1766    let delta_cl_class = delta_cl_class.to_string();
1767    let delta_cml_class = delta_cml_class.to_string();
1768    let delta_bl_class = delta_bl_class.to_string();
1769
1770    // Pre-compute line-level deltas for the line change summary.
1771    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
1772    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
1773    let (delta_lines_net_str, delta_lines_net_class) =
1774        match (delta_lines_added, delta_lines_removed) {
1775            (Some(a), Some(r)) => {
1776                let net = a - r;
1777                (fmt_delta(net), delta_class(net).to_string())
1778            }
1779            _ => ("—".to_string(), "na".to_string()),
1780        };
1781
1782    let template = ResultTemplate {
1783        report_title: run.effective_configuration.reporting.report_title.clone(),
1784        project_path: form.path,
1785        output_dir: display_path(&artifacts.output_dir),
1786        run_id: run_id.clone(),
1787        files_analyzed,
1788        files_skipped,
1789        physical_lines,
1790        code_lines,
1791        comment_lines,
1792        blank_lines,
1793        mixed_lines,
1794        functions,
1795        classes,
1796        variables,
1797        imports,
1798        html_url: artifacts
1799            .html_path
1800            .as_ref()
1801            .map(|_| format!("/runs/{run_id}/html")),
1802        pdf_url: artifacts
1803            .pdf_path
1804            .as_ref()
1805            .map(|_| format!("/runs/{run_id}/pdf")),
1806        json_url: artifacts
1807            .json_path
1808            .as_ref()
1809            .map(|_| format!("/runs/{run_id}/json")),
1810        html_download_url: artifacts
1811            .html_path
1812            .as_ref()
1813            .map(|_| format!("/runs/{run_id}/html?download=1")),
1814        pdf_download_url: artifacts
1815            .pdf_path
1816            .as_ref()
1817            .map(|_| format!("/runs/{run_id}/pdf?download=1")),
1818        json_download_url: artifacts
1819            .json_path
1820            .as_ref()
1821            .map(|_| format!("/runs/{run_id}/json?download=1")),
1822        html_path: artifacts.html_path.as_ref().map(|path| display_path(path)),
1823        pdf_path: artifacts.pdf_path.as_ref().map(|path| display_path(path)),
1824        json_path: artifacts.json_path.as_ref().map(|path| display_path(path)),
1825        language_rows,
1826        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
1827        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_pst(e.timestamp_utc)),
1828        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
1829        prev_fa_str,
1830        prev_fs_str,
1831        prev_pl_str,
1832        prev_cl_str,
1833        prev_cml_str,
1834        prev_bl_str,
1835        delta_fa_str,
1836        delta_fa_class,
1837        delta_fs_str,
1838        delta_fs_class,
1839        delta_pl_str,
1840        delta_pl_class,
1841        delta_cl_str,
1842        delta_cl_class,
1843        delta_cml_str,
1844        delta_cml_class,
1845        delta_bl_str,
1846        delta_bl_class,
1847        // delta metrics derived from the comparison against the previous scan
1848        delta_lines_added,
1849        delta_lines_removed,
1850        delta_lines_net_str,
1851        delta_lines_net_class,
1852        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
1853        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
1854        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
1855        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
1856        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
1857            d.file_deltas
1858                .iter()
1859                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
1860                .map(|f| {
1861                    #[allow(clippy::cast_sign_loss)]
1862                    let n = f.current_code as u64;
1863                    n
1864                })
1865                .sum()
1866        }),
1867        git_branch: git_branch.clone(),
1868        git_commit: git_commit.clone(),
1869        git_author: git_author.clone(),
1870        current_scan_number: prev_scan_count + 1,
1871        prev_scan_count,
1872        submodule_rows: run
1873            .submodule_summaries
1874            .iter()
1875            .map(|s| build_submodule_row(s, &run, &run_id, &run_dir, form.generate_html.is_some()))
1876            .collect(),
1877        scan_config_url: format!("/runs/{run_id}/scan-config"),
1878        csp_nonce,
1879    };
1880
1881    Html(
1882        template
1883            .render()
1884            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1885    )
1886    .into_response()
1887}
1888
1889fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
1890    let slug: String = report_title
1891        .chars()
1892        .map(|c| {
1893            if c.is_alphanumeric() || c == '-' {
1894                c.to_ascii_lowercase()
1895            } else {
1896                '_'
1897            }
1898        })
1899        .collect::<String>()
1900        .split('_')
1901        .filter(|s| !s.is_empty())
1902        .collect::<Vec<_>>()
1903        .join("_");
1904
1905    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
1906
1907    if slug.is_empty() {
1908        format!("report_{short_id}.pdf")
1909    } else {
1910        format!("{slug}_{short_id}.pdf")
1911    }
1912}
1913
1914/// Serve the HTML artifact for a run — view or download.
1915fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
1916    match fs::read_to_string(path) {
1917        Ok(content) => {
1918            if wants_download {
1919                (
1920                    [
1921                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
1922                        (
1923                            header::CONTENT_DISPOSITION,
1924                            "attachment; filename=report.html",
1925                        ),
1926                    ],
1927                    content,
1928                )
1929                    .into_response()
1930            } else {
1931                Html(content).into_response()
1932            }
1933        }
1934        Err(err) => {
1935            let filename = path.file_name().map_or_else(
1936                || "report.html".to_string(),
1937                |n| n.to_string_lossy().into_owned(),
1938            );
1939            let msg = format!(
1940                "HTML report '{filename}' could not be read.\n\n\
1941                 Error: {err}\n\n\
1942                 If you moved or renamed the output folder, the stored path is now stale. \
1943                 Use 'Open HTML folder' from the results page to browse the output directory."
1944            );
1945            let html = ErrorTemplate {
1946                message: msg,
1947                last_report_url: Some("/view-reports".to_string()),
1948                last_report_label: Some("View Reports".to_string()),
1949                csp_nonce: csp_nonce.to_owned(),
1950            }
1951            .render()
1952            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
1953            (StatusCode::NOT_FOUND, Html(html)).into_response()
1954        }
1955    }
1956}
1957
1958/// Serve the PDF artifact for a run — inline or download.
1959fn serve_pdf_artifact(
1960    path: &Path,
1961    report_title: &str,
1962    run_id: &str,
1963    wants_download: bool,
1964    csp_nonce: &str,
1965) -> Response {
1966    match fs::read(path) {
1967        Ok(bytes) => {
1968            let filename = build_pdf_filename(report_title, run_id);
1969            let disposition = if wants_download {
1970                format!("attachment; filename=\"{filename}\"")
1971            } else {
1972                format!("inline; filename=\"{filename}\"")
1973            };
1974            (
1975                [
1976                    (header::CONTENT_TYPE, "application/pdf".to_string()),
1977                    (header::CONTENT_DISPOSITION, disposition),
1978                ],
1979                bytes,
1980            )
1981                .into_response()
1982        }
1983        Err(err) => {
1984            let filename = path.file_name().map_or_else(
1985                || "report.pdf".to_string(),
1986                |n| n.to_string_lossy().into_owned(),
1987            );
1988            let msg = format!(
1989                "PDF report '{filename}' could not be read.\n\n\
1990                 Error: {err}\n\n\
1991                 If you moved or renamed the output folder, the stored path is now stale. \
1992                 Use 'Open PDF folder' from the results page to browse the output directory."
1993            );
1994            let html = ErrorTemplate {
1995                message: msg,
1996                last_report_url: Some("/view-reports".to_string()),
1997                last_report_label: Some("View Reports".to_string()),
1998                csp_nonce: csp_nonce.to_owned(),
1999            }
2000            .render()
2001            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
2002            (StatusCode::NOT_FOUND, Html(html)).into_response()
2003        }
2004    }
2005}
2006
2007/// Serve the JSON artifact for a run — view or download.
2008fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
2009    match fs::read(path) {
2010        Ok(bytes) => {
2011            if wants_download {
2012                (
2013                    [
2014                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
2015                        (
2016                            header::CONTENT_DISPOSITION,
2017                            "attachment; filename=result.json",
2018                        ),
2019                    ],
2020                    bytes,
2021                )
2022                    .into_response()
2023            } else {
2024                (
2025                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
2026                    bytes,
2027                )
2028                    .into_response()
2029            }
2030        }
2031        Err(err) => {
2032            let filename = path.file_name().map_or_else(
2033                || "result.json".to_string(),
2034                |n| n.to_string_lossy().into_owned(),
2035            );
2036            let msg = format!(
2037                "JSON result '{filename}' could not be read.\n\n\
2038                 Error: {err}\n\n\
2039                 If you moved or renamed the output folder, the stored path is now stale. \
2040                 Use 'Open JSON folder' from the results page to browse the output directory."
2041            );
2042            let html = ErrorTemplate {
2043                message: msg,
2044                last_report_url: Some("/view-reports".to_string()),
2045                last_report_label: Some("View Reports".to_string()),
2046                csp_nonce: csp_nonce.to_owned(),
2047            }
2048            .render()
2049            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
2050            (StatusCode::NOT_FOUND, Html(html)).into_response()
2051        }
2052    }
2053}
2054
2055/// Recover a `RunArtifacts` from the persisted registry for a run ID.
2056fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
2057    let output_dir = entry
2058        .html_path
2059        .as_ref()
2060        .or(entry.json_path.as_ref())
2061        .or(entry.pdf_path.as_ref())
2062        .and_then(|p| p.parent().map(PathBuf::from))
2063        .unwrap_or_default();
2064    // Recover pdf_path: use the persisted one, or look for report.pdf
2065    // adjacent to html/json if only the old entries lack it.
2066    let pdf_path = entry.pdf_path.clone().or_else(|| {
2067        let candidate = output_dir.join("report.pdf");
2068        candidate.exists().then_some(candidate)
2069    });
2070    RunArtifacts {
2071        output_dir,
2072        html_path: entry.html_path.clone(),
2073        pdf_path,
2074        json_path: entry.json_path.clone(),
2075        report_title: entry.project_label.clone(),
2076    }
2077}
2078
2079#[allow(clippy::too_many_lines)]
2080async fn artifact_handler(
2081    State(state): State<AppState>,
2082    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2083    AxumPath((run_id, artifact)): AxumPath<(String, String)>,
2084    Query(query): Query<ArtifactQuery>,
2085) -> Response {
2086    let artifact_set = {
2087        let registry = state.artifacts.lock().await;
2088        registry.get(&run_id).cloned()
2089    };
2090
2091    // Fall back to the persisted registry when the server was restarted and the
2092    // in-memory artifact map no longer holds the entry.
2093    let artifact_set = if let Some(a) = artifact_set {
2094        a
2095    } else {
2096        let reg = state.registry.lock().await;
2097        if let Some(entry) = reg.find_by_run_id(&run_id) {
2098            recover_artifacts_from_registry(entry)
2099        } else {
2100            let error_html = ErrorTemplate {
2101                message: format!(
2102                    "Report not found. Run ID {} is not in the scan history. \
2103                     The report may have been deleted, or this is an old run from \
2104                     before the scan registry was introduced.",
2105                    &run_id[..run_id.len().min(8)]
2106                ),
2107                last_report_url: Some("/view-reports".to_string()),
2108                last_report_label: Some("View Reports".to_string()),
2109                csp_nonce: csp_nonce.clone(),
2110            }
2111            .render()
2112            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
2113            return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
2114        }
2115    };
2116
2117    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
2118
2119    match artifact.as_str() {
2120        "html" => {
2121            let Some(path) = artifact_set.html_path else {
2122                return StatusCode::NOT_FOUND.into_response();
2123            };
2124            serve_html_artifact(&path, wants_download, &csp_nonce)
2125        }
2126        "pdf" => {
2127            let Some(path) = artifact_set.pdf_path else {
2128                let msg = "PDF report was not generated for this run, or was not recorded in \
2129                           the scan registry. Re-run the analysis with PDF output enabled."
2130                    .to_string();
2131                let html = ErrorTemplate {
2132                    message: msg,
2133                    last_report_url: Some("/view-reports".to_string()),
2134                    last_report_label: Some("View Reports".to_string()),
2135                    csp_nonce: csp_nonce.clone(),
2136                }
2137                .render()
2138                .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
2139                return (StatusCode::NOT_FOUND, Html(html)).into_response();
2140            };
2141            serve_pdf_artifact(
2142                &path,
2143                &artifact_set.report_title,
2144                &run_id,
2145                wants_download,
2146                &csp_nonce,
2147            )
2148        }
2149        "json" => {
2150            let Some(path) = artifact_set.json_path else {
2151                let msg = "JSON result was not generated for this run, or was not recorded in \
2152                           the scan registry. Re-run the analysis with JSON output enabled."
2153                    .to_string();
2154                let html = ErrorTemplate {
2155                    message: msg,
2156                    last_report_url: Some("/view-reports".to_string()),
2157                    last_report_label: Some("View Reports".to_string()),
2158                    csp_nonce: csp_nonce.clone(),
2159                }
2160                .render()
2161                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
2162                return (StatusCode::NOT_FOUND, Html(html)).into_response();
2163            };
2164            serve_json_artifact(&path, wants_download, &csp_nonce)
2165        }
2166        "scan-config" => {
2167            let path = artifact_set.output_dir.join("scan-config.json");
2168            fs::read(&path).map_or_else(
2169                |_| StatusCode::NOT_FOUND.into_response(),
2170                |bytes| {
2171                    (
2172                        [
2173                            (
2174                                header::CONTENT_TYPE,
2175                                "application/json; charset=utf-8".to_string(),
2176                            ),
2177                            (
2178                                header::CONTENT_DISPOSITION,
2179                                "attachment; filename=\"scan-config.json\"".to_string(),
2180                            ),
2181                        ],
2182                        bytes,
2183                    )
2184                        .into_response()
2185                },
2186            )
2187        }
2188        _ if artifact.starts_with("sub_") => {
2189            if artifact.len() > 128
2190                || !artifact
2191                    .chars()
2192                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2193            {
2194                return StatusCode::BAD_REQUEST.into_response();
2195            }
2196            let filename = format!("{artifact}.html");
2197            let path = artifact_set.output_dir.join(&filename);
2198            fs::read_to_string(&path).map_or_else(
2199                |_| {
2200                    let html = ErrorTemplate {
2201                        message: format!(
2202                            "Sub-report '{artifact}' was not found in the run directory.\n\
2203                             Re-run the analysis with 'Detect and separate git submodules' \
2204                             and HTML output enabled."
2205                        ),
2206                        last_report_url: Some("/view-reports".to_string()),
2207                        last_report_label: Some("View Reports".to_string()),
2208                        csp_nonce: csp_nonce.clone(),
2209                    }
2210                    .render()
2211                    .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
2212                    (StatusCode::NOT_FOUND, Html(html)).into_response()
2213                },
2214                |content| Html(content).into_response(),
2215            )
2216        }
2217        _ => StatusCode::NOT_FOUND.into_response(),
2218    }
2219}
2220
2221// ── History ───────────────────────────────────────────────────────────────────
2222
2223struct HistoryEntryRow {
2224    run_id: String,
2225    run_id_short: String,
2226    timestamp: String,
2227    project_label: String,
2228    project_path: String,
2229    files_analyzed: u64,
2230    files_skipped: u64,
2231    code_lines: u64,
2232    comment_lines: u64,
2233    blank_lines: u64,
2234    functions: u64,
2235    classes: u64,
2236    variables: u64,
2237    imports: u64,
2238    git_branch: String,
2239    git_commit: String,
2240    has_html: bool,
2241    has_json: bool,
2242    has_pdf: bool,
2243}
2244
2245fn fmt_pst(dt: chrono::DateTime<chrono::Utc>) -> String {
2246    dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset is always valid"))
2247        .format("%Y-%m-%d %H:%M PST")
2248        .to_string()
2249}
2250
2251fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
2252    reg.entries
2253        .iter()
2254        .map(|e| HistoryEntryRow {
2255            run_id: e.run_id.clone(),
2256            run_id_short: e
2257                .run_id
2258                .split('-')
2259                .next_back()
2260                .unwrap_or(&e.run_id)
2261                .chars()
2262                .take(7)
2263                .collect(),
2264            timestamp: fmt_pst(e.timestamp_utc),
2265            project_label: e.project_label.clone(),
2266            project_path: e
2267                .input_roots
2268                .first()
2269                .map(|s| sanitize_path_str(s))
2270                .unwrap_or_default(),
2271            files_analyzed: e.summary.files_analyzed,
2272            files_skipped: e.summary.files_skipped,
2273            code_lines: e.summary.code_lines,
2274            comment_lines: e.summary.comment_lines,
2275            blank_lines: e.summary.blank_lines,
2276            functions: e.summary.functions,
2277            classes: e.summary.classes,
2278            variables: e.summary.variables,
2279            imports: e.summary.imports,
2280            git_branch: e.git_branch.clone().unwrap_or_default(),
2281            git_commit: e.git_commit.clone().unwrap_or_default(),
2282            has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
2283            has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
2284            has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
2285        })
2286        .collect()
2287}
2288
2289#[derive(Deserialize, Default)]
2290struct HistoryQuery {
2291    linked: Option<String>,
2292}
2293
2294async fn history_handler(
2295    State(state): State<AppState>,
2296    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2297    Query(query): Query<HistoryQuery>,
2298) -> impl IntoResponse {
2299    let mut entries = {
2300        let reg = state.registry.lock().await;
2301        make_history_rows(&reg)
2302    };
2303    entries.retain(|e| e.has_html);
2304    let total_scans = entries.len();
2305    let linked = query.linked.as_deref() == Some("1");
2306    let template = HistoryTemplate {
2307        entries,
2308        total_scans,
2309        linked,
2310        csp_nonce,
2311    };
2312    Html(
2313        template
2314            .render()
2315            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2316    )
2317    .into_response()
2318}
2319
2320async fn compare_select_handler(
2321    State(state): State<AppState>,
2322    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2323) -> impl IntoResponse {
2324    let mut entries = {
2325        let reg = state.registry.lock().await;
2326        make_history_rows(&reg)
2327    };
2328    entries.retain(|e| e.has_json);
2329    let total_scans = entries.len();
2330    let template = CompareSelectTemplate {
2331        entries,
2332        total_scans,
2333        csp_nonce,
2334    };
2335    Html(
2336        template
2337            .render()
2338            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2339    )
2340    .into_response()
2341}
2342
2343// ── Compare ───────────────────────────────────────────────────────────────────
2344
2345#[derive(Deserialize, Default)]
2346struct CompareQuery {
2347    a: Option<String>,
2348    b: Option<String>,
2349}
2350
2351struct CompareFileDeltaRow {
2352    relative_path: String,
2353    language: String,
2354    status: String,
2355    baseline_code: i64,
2356    current_code: i64,
2357    code_delta_str: String,
2358    code_delta_class: String,
2359    comment_delta_str: String,
2360    comment_delta_class: String,
2361    total_delta_str: String,
2362    total_delta_class: String,
2363}
2364
2365fn fmt_delta(n: i64) -> String {
2366    if n > 0 {
2367        format!("+{n}")
2368    } else {
2369        format!("{n}")
2370    }
2371}
2372
2373fn delta_class(n: i64) -> &'static str {
2374    use std::cmp::Ordering;
2375    match n.cmp(&0) {
2376        Ordering::Greater => "pos",
2377        Ordering::Less => "neg",
2378        Ordering::Equal => "zero",
2379    }
2380}
2381
2382/// Returns (`display_string`, `css_class`) for a numeric change column cell.
2383fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
2384    prev.map_or_else(
2385        || ("—".to_string(), "na"),
2386        |p| {
2387            #[allow(clippy::cast_possible_wrap)]
2388            let d = curr as i64 - p as i64;
2389            (fmt_delta(d), delta_class(d))
2390        },
2391    )
2392}
2393
2394#[allow(clippy::too_many_lines)]
2395async fn compare_handler(
2396    State(state): State<AppState>,
2397    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2398    Query(query): Query<CompareQuery>,
2399) -> impl IntoResponse {
2400    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
2401    // redirect to the history page where the user can select two runs.
2402    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
2403        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
2404        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
2405    };
2406
2407    let (maybe_a, maybe_b) = {
2408        let reg = state.registry.lock().await;
2409        (
2410            reg.find_by_run_id(&run_id_a).cloned(),
2411            reg.find_by_run_id(&run_id_b).cloned(),
2412        )
2413    };
2414
2415    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
2416        let html = ErrorTemplate {
2417            message: "One or both run IDs were not found in scan history. \
2418                      The runs may have been deleted or the registry may have been reset."
2419                .to_string(),
2420            last_report_url: Some("/compare-scans".to_string()),
2421            last_report_label: Some("Compare Scans".to_string()),
2422            csp_nonce: csp_nonce.clone(),
2423        }
2424        .render()
2425        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
2426        return Html(html).into_response();
2427    };
2428
2429    // Ensure older scan is always the baseline.
2430    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
2431        (entry_a, entry_b)
2432    } else {
2433        (entry_b, entry_a)
2434    };
2435
2436    // If query params were in the wrong order, redirect to canonical URL so the
2437    // browser always shows the same URL for the same two scans regardless of how
2438    // the user arrived here (Full diff button vs. Compare Scans selection).
2439    if baseline_entry.run_id != run_id_a {
2440        let canonical = format!(
2441            "/compare?a={}&b={}",
2442            baseline_entry.run_id, current_entry.run_id
2443        );
2444        return axum::response::Redirect::to(&canonical).into_response();
2445    }
2446
2447    let (Some(base_json), Some(curr_json)) = (
2448        baseline_entry.json_path.as_ref(),
2449        current_entry.json_path.as_ref(),
2450    ) else {
2451        let html = ErrorTemplate {
2452            message: "Full comparison requires JSON scan data, which was not saved for one or \
2453                      both of these runs. JSON is now always saved for new scans — re-run the \
2454                      affected projects to enable comparisons."
2455                .to_string(),
2456            last_report_url: Some("/compare-scans".to_string()),
2457            last_report_label: Some("Compare Scans".to_string()),
2458            csp_nonce: csp_nonce.clone(),
2459        }
2460        .render()
2461        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
2462        return Html(html).into_response();
2463    };
2464
2465    let baseline_run = match read_json(base_json) {
2466        Ok(r) => r,
2467        Err(e) => {
2468            let message = if state.server_mode {
2469                "Could not load baseline scan data. The scan output folder may have been moved, \
2470                 renamed, or deleted. Re-running the analysis will create fresh comparison data."
2471                    .to_string()
2472            } else {
2473                format!(
2474                    "Could not load baseline scan data.\n\nPath: {}\n\nError: {e}\n\n\
2475                     The scan output folder may have been moved, renamed, or deleted. \
2476                     Re-running the analysis for this project will create fresh comparison data.",
2477                    base_json.display()
2478                )
2479            };
2480            let html = ErrorTemplate {
2481                message,
2482                last_report_url: Some("/compare-scans".to_string()),
2483                last_report_label: Some("Compare Scans".to_string()),
2484                csp_nonce: csp_nonce.clone(),
2485            }
2486            .render()
2487            .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
2488            return (StatusCode::NOT_FOUND, Html(html)).into_response();
2489        }
2490    };
2491    let current_run = match read_json(curr_json) {
2492        Ok(r) => r,
2493        Err(e) => {
2494            let message = if state.server_mode {
2495                "Could not load current scan data. The scan output folder may have been moved, \
2496                 renamed, or deleted. Re-running the analysis will create fresh comparison data."
2497                    .to_string()
2498            } else {
2499                format!(
2500                    "Could not load current scan data.\n\nPath: {}\n\nError: {e}\n\n\
2501                     The scan output folder may have been moved, renamed, or deleted. \
2502                     Re-running the analysis for this project will create fresh comparison data.",
2503                    curr_json.display()
2504                )
2505            };
2506            let html = ErrorTemplate {
2507                message,
2508                last_report_url: Some("/compare-scans".to_string()),
2509                last_report_label: Some("Compare Scans".to_string()),
2510                csp_nonce: csp_nonce.clone(),
2511            }
2512            .render()
2513            .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
2514            return (StatusCode::NOT_FOUND, Html(html)).into_response();
2515        }
2516    };
2517
2518    let comparison = compute_delta(&baseline_run, &current_run);
2519
2520    let file_rows: Vec<CompareFileDeltaRow> = comparison
2521        .file_deltas
2522        .iter()
2523        .map(|d| CompareFileDeltaRow {
2524            relative_path: d.relative_path.clone(),
2525            language: d.language.clone().unwrap_or_else(|| "—".into()),
2526            status: match d.status {
2527                FileChangeStatus::Added => "added".into(),
2528                FileChangeStatus::Removed => "removed".into(),
2529                FileChangeStatus::Modified => "modified".into(),
2530                FileChangeStatus::Unchanged => "unchanged".into(),
2531            },
2532            baseline_code: d.baseline_code,
2533            current_code: d.current_code,
2534            code_delta_str: fmt_delta(d.code_delta),
2535            code_delta_class: delta_class(d.code_delta).into(),
2536            comment_delta_str: fmt_delta(d.comment_delta),
2537            comment_delta_class: delta_class(d.comment_delta).into(),
2538            total_delta_str: fmt_delta(d.total_delta),
2539            total_delta_class: delta_class(d.total_delta).into(),
2540        })
2541        .collect();
2542
2543    let project_path = baseline_entry
2544        .input_roots
2545        .first()
2546        .map(|s| sanitize_path_str(s))
2547        .unwrap_or_default();
2548    let s = &comparison.summary;
2549    let template = CompareTemplate {
2550        baseline_run_id: baseline_entry.run_id.clone(),
2551        current_run_id: current_entry.run_id.clone(),
2552        baseline_run_id_short: baseline_entry
2553            .run_id
2554            .split('-')
2555            .next_back()
2556            .unwrap_or(&baseline_entry.run_id)
2557            .chars()
2558            .take(7)
2559            .collect(),
2560        current_run_id_short: current_entry
2561            .run_id
2562            .split('-')
2563            .next_back()
2564            .unwrap_or(&current_entry.run_id)
2565            .chars()
2566            .take(7)
2567            .collect(),
2568        baseline_timestamp: fmt_pst(baseline_entry.timestamp_utc),
2569        current_timestamp: fmt_pst(current_entry.timestamp_utc),
2570        project_path,
2571        baseline_code: s.baseline_code,
2572        current_code: s.current_code,
2573        code_lines_delta_str: fmt_delta(s.code_lines_delta),
2574        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
2575        baseline_files: s.baseline_files,
2576        current_files: s.current_files,
2577        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
2578        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
2579        baseline_comments: s.baseline_comments,
2580        current_comments: s.current_comments,
2581        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
2582        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
2583        files_added: comparison.files_added,
2584        files_removed: comparison.files_removed,
2585        files_modified: comparison.files_modified,
2586        files_unchanged: comparison.files_unchanged,
2587        file_rows,
2588        baseline_git_author: baseline_entry.git_author.clone(),
2589        current_git_author: current_entry.git_author.clone(),
2590        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
2591        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
2592        baseline_git_tags: baseline_entry.git_tags.clone(),
2593        current_git_tags: current_entry.git_tags.clone(),
2594        csp_nonce,
2595    };
2596
2597    Html(
2598        template
2599            .render()
2600            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2601    )
2602    .into_response()
2603}
2604
2605// ── Badge endpoint ────────────────────────────────────────────────────────────
2606// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
2607// pages, Jira descriptions, etc.
2608//
2609// GET /badge/<metric>?label=<override>&color=<hex>
2610// Metrics: code-lines  files  comment-lines  blank-lines
2611
2612fn format_number(n: u64) -> String {
2613    let s = n.to_string();
2614    let mut out = String::with_capacity(s.len() + s.len() / 3);
2615    let len = s.len();
2616    for (i, c) in s.chars().enumerate() {
2617        if i > 0 && (len - i).is_multiple_of(3) {
2618            out.push(',');
2619        }
2620        out.push(c);
2621    }
2622    out
2623}
2624
2625const fn badge_char_width(c: char) -> f64 {
2626    match c {
2627        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
2628        'm' | 'w' => 9.0,
2629        ' ' => 4.0,
2630        _ => 6.5,
2631    }
2632}
2633
2634#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2635fn badge_text_px(text: &str) -> u32 {
2636    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
2637}
2638
2639fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
2640    let lw = badge_text_px(label) + 20;
2641    let rw = badge_text_px(value) + 20;
2642    let total = lw + rw;
2643    let lx = lw / 2;
2644    let rx = lw + rw / 2;
2645    let le = escape_html(label);
2646    let ve = escape_html(value);
2647    let ce = escape_html(color);
2648    format!(
2649        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
2650  <rect width="{total}" height="20" fill="#555"/>
2651  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
2652  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
2653    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
2654    <text x="{lx}" y="13">{le}</text>
2655    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
2656    <text x="{rx}" y="13">{ve}</text>
2657  </g>
2658</svg>"##
2659    )
2660}
2661
2662#[derive(Deserialize)]
2663struct BadgeQuery {
2664    label: Option<String>,
2665    color: Option<String>,
2666}
2667
2668async fn badge_handler(
2669    State(state): State<AppState>,
2670    AxumPath(metric): AxumPath<String>,
2671    Query(query): Query<BadgeQuery>,
2672) -> Response {
2673    let entry = {
2674        let reg = state.registry.lock().await;
2675        reg.entries.first().cloned()
2676    };
2677
2678    let Some(entry) = entry else {
2679        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
2680        return (
2681            [
2682                (header::CONTENT_TYPE, "image/svg+xml"),
2683                (header::CACHE_CONTROL, "no-cache, max-age=0"),
2684            ],
2685            svg,
2686        )
2687            .into_response();
2688    };
2689
2690    let (default_label, value, default_color) = match metric.as_str() {
2691        "code-lines" => (
2692            "code lines",
2693            format_number(entry.summary.code_lines),
2694            "#4a78ee",
2695        ),
2696        "files" => (
2697            "files analyzed",
2698            format_number(entry.summary.files_analyzed),
2699            "#4a9862",
2700        ),
2701        "comment-lines" => (
2702            "comment lines",
2703            format_number(entry.summary.comment_lines),
2704            "#b35428",
2705        ),
2706        "blank-lines" => (
2707            "blank lines",
2708            format_number(entry.summary.blank_lines),
2709            "#7a5db0",
2710        ),
2711        _ => return StatusCode::NOT_FOUND.into_response(),
2712    };
2713
2714    let label = query.label.as_deref().unwrap_or(default_label);
2715    let color = query.color.as_deref().unwrap_or(default_color);
2716    let svg = render_badge_svg(label, &value, color);
2717
2718    (
2719        [
2720            (header::CONTENT_TYPE, "image/svg+xml"),
2721            (header::CACHE_CONTROL, "no-cache, max-age=0"),
2722        ],
2723        svg,
2724    )
2725        .into_response()
2726}
2727
2728// ── Metrics API ───────────────────────────────────────────────────────────────
2729// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
2730// Confluence automation, Jira webhooks, etc.
2731//
2732// GET /api/metrics/latest
2733// GET /api/metrics/<run_id>
2734
2735#[derive(Serialize)]
2736struct ApiMetricsResponse {
2737    run_id: String,
2738    timestamp: String,
2739    project: String,
2740    summary: ApiSummaryPayload,
2741    languages: Vec<ApiLanguageRow>,
2742}
2743
2744#[derive(Serialize)]
2745struct ApiSummaryPayload {
2746    files_analyzed: u64,
2747    files_skipped: u64,
2748    code_lines: u64,
2749    comment_lines: u64,
2750    blank_lines: u64,
2751    total_physical_lines: u64,
2752    functions: u64,
2753    classes: u64,
2754    variables: u64,
2755    imports: u64,
2756}
2757
2758#[derive(Serialize)]
2759struct ApiLanguageRow {
2760    name: String,
2761    files: u64,
2762    code_lines: u64,
2763    comment_lines: u64,
2764    blank_lines: u64,
2765    functions: u64,
2766    classes: u64,
2767    variables: u64,
2768    imports: u64,
2769}
2770
2771async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
2772    let entry = {
2773        let reg = state.registry.lock().await;
2774        reg.entries.first().cloned()
2775    };
2776    entry.map_or_else(
2777        || {
2778            (
2779                StatusCode::NOT_FOUND,
2780                Json(serde_json::json!({"error": "no scans recorded yet"})),
2781            )
2782                .into_response()
2783        },
2784        |e| build_metrics_response(&e),
2785    )
2786}
2787
2788async fn api_metrics_run_handler(
2789    State(state): State<AppState>,
2790    AxumPath(run_id): AxumPath<String>,
2791) -> Response {
2792    let entry = {
2793        let reg = state.registry.lock().await;
2794        reg.find_by_run_id(&run_id).cloned()
2795    };
2796    entry.map_or_else(
2797        || {
2798            (
2799                StatusCode::NOT_FOUND,
2800                Json(serde_json::json!({"error": "run not found"})),
2801            )
2802                .into_response()
2803        },
2804        |e| build_metrics_response(&e),
2805    )
2806}
2807
2808fn build_metrics_response(entry: &RegistryEntry) -> Response {
2809    let languages: Vec<ApiLanguageRow> = entry
2810        .json_path
2811        .as_ref()
2812        .and_then(|p| read_json(p).ok())
2813        .map(|run| {
2814            run.totals_by_language
2815                .iter()
2816                .map(|l| ApiLanguageRow {
2817                    name: l.language.display_name().to_string(),
2818                    files: l.files,
2819                    code_lines: l.code_lines,
2820                    comment_lines: l.comment_lines,
2821                    blank_lines: l.blank_lines,
2822                    functions: l.functions,
2823                    classes: l.classes,
2824                    variables: l.variables,
2825                    imports: l.imports,
2826                })
2827                .collect()
2828        })
2829        .unwrap_or_default();
2830
2831    let s = &entry.summary;
2832    Json(ApiMetricsResponse {
2833        run_id: entry.run_id.clone(),
2834        timestamp: entry.timestamp_utc.to_rfc3339(),
2835        project: entry.project_label.clone(),
2836        summary: ApiSummaryPayload {
2837            files_analyzed: s.files_analyzed,
2838            files_skipped: s.files_skipped,
2839            code_lines: s.code_lines,
2840            comment_lines: s.comment_lines,
2841            blank_lines: s.blank_lines,
2842            total_physical_lines: s.total_physical_lines,
2843            functions: s.functions,
2844            classes: s.classes,
2845            variables: s.variables,
2846            imports: s.imports,
2847        },
2848        languages,
2849    })
2850    .into_response()
2851}
2852
2853// ── Project history API ───────────────────────────────────────────────────────
2854// Protected. Called by the wizard JS when the project path changes, so the UI
2855// can show a "scanned N times before" badge without a full page reload.
2856//
2857// GET /api/project-history?path=<project_root>
2858
2859#[derive(Deserialize)]
2860struct ProjectHistoryQuery {
2861    path: Option<String>,
2862}
2863
2864#[derive(Serialize)]
2865struct ProjectHistoryResponse {
2866    scan_count: usize,
2867    last_scan_id: Option<String>,
2868    last_scan_timestamp: Option<String>,
2869    last_scan_code_lines: Option<u64>,
2870    last_git_branch: Option<String>,
2871    last_git_commit: Option<String>,
2872}
2873
2874async fn project_history_handler(
2875    State(state): State<AppState>,
2876    Query(query): Query<ProjectHistoryQuery>,
2877) -> Response {
2878    let path = query.path.unwrap_or_default();
2879    let resolved = resolve_input_path(&path);
2880    let root_str = resolved.to_string_lossy().replace('\\', "/");
2881
2882    let entries: Vec<_> = {
2883        let reg = state.registry.lock().await;
2884        reg.entries
2885            .iter()
2886            .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
2887            .cloned()
2888            .collect()
2889    };
2890    let scan_count = entries.len();
2891    let last = entries.first();
2892    let last_scan_id = last.map(|e| e.run_id.clone());
2893    let last_scan_timestamp = last.map(|e| fmt_pst(e.timestamp_utc));
2894    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
2895    let last_git_branch = last.and_then(|e| e.git_branch.clone());
2896    let last_git_commit = last.and_then(|e| e.git_commit.clone());
2897
2898    Json(ProjectHistoryResponse {
2899        scan_count,
2900        last_scan_id,
2901        last_scan_timestamp,
2902        last_scan_code_lines,
2903        last_git_branch,
2904        last_git_commit,
2905    })
2906    .into_response()
2907}
2908
2909// ── Embeddable widget ─────────────────────────────────────────────────────────
2910// Protected. Returns a self-contained HTML page suitable for iframing inside
2911// Jenkins build summaries, Confluence iframe macros, or Jira panels.
2912//
2913// GET /embed/summary?run_id=<uuid>&theme=dark
2914
2915#[derive(Deserialize)]
2916struct EmbedQuery {
2917    run_id: Option<String>,
2918    theme: Option<String>,
2919}
2920
2921async fn embed_handler(
2922    State(state): State<AppState>,
2923    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2924    Query(query): Query<EmbedQuery>,
2925) -> Response {
2926    let entry = {
2927        let reg = state.registry.lock().await;
2928        query.run_id.as_ref().map_or_else(
2929            || reg.entries.first().cloned(),
2930            |id| reg.find_by_run_id(id).cloned(),
2931        )
2932    };
2933
2934    let Some(entry) = entry else {
2935        return Html(
2936            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
2937                .to_string(),
2938        )
2939        .into_response();
2940    };
2941
2942    let dark = query.theme.as_deref() == Some("dark");
2943    let languages: Vec<(String, u64, u64)> = entry
2944        .json_path
2945        .as_ref()
2946        .and_then(|p| read_json(p).ok())
2947        .map(|run| {
2948            run.totals_by_language
2949                .iter()
2950                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
2951                .collect()
2952        })
2953        .unwrap_or_default();
2954
2955    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
2956}
2957
2958fn render_embed_widget(
2959    entry: &RegistryEntry,
2960    languages: &[(String, u64, u64)],
2961    dark: bool,
2962    csp_nonce: &str,
2963) -> String {
2964    let s = &entry.summary;
2965    let total = s.code_lines + s.comment_lines + s.blank_lines;
2966    let code_pct = s
2967        .code_lines
2968        .checked_mul(100)
2969        .and_then(|n| n.checked_div(total))
2970        .unwrap_or(0);
2971
2972    let (bg, fg, surface, muted, border) = if dark {
2973        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
2974    } else {
2975        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
2976    };
2977
2978    let mut lang_rows = String::new();
2979    for (name, files, code) in languages {
2980        write!(
2981            lang_rows,
2982            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
2983            escape_html(name),
2984            format_number(*files),
2985            format_number(*code),
2986        )
2987        .ok();
2988    }
2989
2990    let lang_table = if lang_rows.is_empty() {
2991        String::new()
2992    } else {
2993        format!(
2994            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
2995        )
2996    };
2997
2998    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
2999    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
3000    let project_esc = escape_html(&entry.project_label);
3001    let code_lines = format_number(s.code_lines);
3002    let comment_lines = format_number(s.comment_lines);
3003    let files = format_number(s.files_analyzed);
3004    let code_raw = s.code_lines;
3005    let comment_raw = s.comment_lines;
3006    let blank_raw = s.blank_lines;
3007
3008    format!(
3009        r#"<!doctype html>
3010<html lang="en">
3011<head>
3012  <meta charset="utf-8">
3013  <meta name="viewport" content="width=device-width,initial-scale=1">
3014  <title>OxideSLOC &mdash; {project_esc}</title>
3015  <script src="/static/chart.js"></script>
3016  <style nonce="{csp_nonce}">
3017    *{{box-sizing:border-box;margin:0;padding:0}}
3018    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
3019    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
3020    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
3021    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
3022    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
3023    .card .v{{font-size:18px;font-weight:700}}
3024    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
3025    .row{{display:flex;gap:12px;align-items:flex-start}}
3026    .pie{{width:120px;height:120px;flex-shrink:0}}
3027    .lt{{border-collapse:collapse;width:100%;flex:1}}
3028    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
3029    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
3030    .n{{text-align:right}}
3031    .footer{{margin-top:10px;color:{muted};font-size:10px}}
3032  </style>
3033</head>
3034<body>
3035  <h2>{project_esc}</h2>
3036  <div class="sub">{timestamp} &middot; run {run_short}</div>
3037  <div class="cards">
3038    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
3039    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
3040    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
3041    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
3042  </div>
3043  <div class="row">
3044    <canvas class="pie" id="c"></canvas>
3045    {lang_table}
3046  </div>
3047  <div class="footer">oxide-sloc</div>
3048  <script nonce="{csp_nonce}">
3049    new Chart(document.getElementById('c'),{{
3050      type:'doughnut',
3051      data:{{
3052        labels:['Code','Comments','Blank'],
3053        datasets:[{{
3054          data:[{code_raw},{comment_raw},{blank_raw}],
3055          backgroundColor:['#4a78ee','#b35428','#aaa'],
3056          borderWidth:0
3057        }}]
3058      }},
3059      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
3060    }});
3061  </script>
3062</body>
3063</html>"#
3064    )
3065}
3066
3067fn persist_run_artifacts(
3068    run: &sloc_core::AnalysisRun,
3069    report_html: &str,
3070    run_dir: &Path,
3071    generate_json: bool,
3072    generate_html: bool,
3073    generate_pdf: bool,
3074    report_title: &str,
3075) -> Result<(RunArtifacts, PendingPdf)> {
3076    fs::create_dir_all(run_dir)
3077        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
3078
3079    let mut html_path = None;
3080    let mut pdf_path = None;
3081    let mut json_path = None;
3082    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
3083
3084    if generate_html {
3085        let path = run_dir.join("report.html");
3086        fs::write(&path, report_html)
3087            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
3088        html_path = Some(path);
3089    }
3090
3091    if generate_json {
3092        let path = run_dir.join("result.json");
3093        let json = serde_json::to_string_pretty(run)
3094            .context("failed to serialize analysis run to JSON")?;
3095        fs::write(&path, json)
3096            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
3097        json_path = Some(path);
3098    }
3099
3100    if generate_pdf {
3101        let source_html_path = if let Some(existing) = html_path.as_ref() {
3102            existing.clone()
3103        } else {
3104            let temp_html = run_dir.join("_report_rendered.html");
3105            fs::write(&temp_html, report_html).with_context(|| {
3106                format!(
3107                    "failed to write temporary HTML report to {}",
3108                    temp_html.display()
3109                )
3110            })?;
3111            temp_html
3112        };
3113
3114        let pdf_dest = run_dir.join("report.pdf");
3115        let cleanup_src = !generate_html;
3116        pdf_path = Some(pdf_dest.clone());
3117        pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
3118    }
3119
3120    Ok((
3121        RunArtifacts {
3122            output_dir: run_dir.to_path_buf(),
3123            html_path,
3124            pdf_path,
3125            json_path,
3126            report_title: report_title.to_string(),
3127        },
3128        pending_pdf,
3129    ))
3130}
3131
3132fn resolve_output_root(raw: Option<&str>) -> PathBuf {
3133    let value = raw.unwrap_or("out/web").trim();
3134    let path = if value.is_empty() {
3135        PathBuf::from("out/web")
3136    } else {
3137        PathBuf::from(value)
3138    };
3139
3140    if path.is_absolute() {
3141        path
3142    } else {
3143        workspace_root().join(path)
3144    }
3145}
3146
3147/// Derive the directory that holds remote-repo clones from the output root.
3148fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
3149    std::env::var("SLOC_GIT_CLONES_DIR")
3150        .map(PathBuf::from)
3151        .unwrap_or_else(|_| output_root.join("git-clones"))
3152}
3153
3154/// Build a deterministic filesystem path for a cloned remote repository.
3155/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
3156pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
3157    let safe: String = repo_url
3158        .chars()
3159        .map(|c| {
3160            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
3161                c
3162            } else {
3163                '_'
3164            }
3165        })
3166        .take(80)
3167        .collect();
3168    clones_dir.join(safe)
3169}
3170
3171/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
3172/// Runs synchronously — call from `tokio::task::spawn_blocking`.
3173pub(crate) fn scan_path_to_artifacts(
3174    scan_path: &Path,
3175    base_config: &AppConfig,
3176    label: &str,
3177) -> Result<String> {
3178    let mut config = base_config.clone();
3179    config.discovery.root_paths = vec![scan_path.to_path_buf()];
3180    config.reporting.report_title = label.to_owned();
3181    let run = analyze(&config, "git")?;
3182    let html = render_html(&run)?;
3183    let run_id = run.tool.run_id.clone();
3184    let project_label = sanitize_project_label(label);
3185    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
3186    persist_run_artifacts(&run, &html, &output_dir, true, true, false, label)?;
3187    Ok(run_id)
3188}
3189
3190/// Re-spawn background poll tasks for any polling schedules saved to disk.
3191async fn restart_poll_schedules(state: &AppState) {
3192    let store = state.schedules.lock().await;
3193    let poll_schedules: Vec<_> = store
3194        .schedules
3195        .iter()
3196        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
3197        .cloned()
3198        .collect();
3199    drop(store);
3200    for schedule in poll_schedules {
3201        let interval = schedule.interval_secs.unwrap_or(300);
3202        let st = state.clone();
3203        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
3204    }
3205}
3206
3207fn split_patterns(raw: Option<&str>) -> Vec<String> {
3208    raw.unwrap_or("")
3209        .lines()
3210        .flat_map(|line| line.split(','))
3211        .map(str::trim)
3212        .filter(|part| !part.is_empty())
3213        .map(ToOwned::to_owned)
3214        .collect()
3215}
3216
3217fn build_sub_run(
3218    parent: &AnalysisRun,
3219    sub: &sloc_core::SubmoduleSummary,
3220    parent_path: &str,
3221) -> AnalysisRun {
3222    let sub_files: Vec<_> = parent
3223        .per_file_records
3224        .iter()
3225        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
3226        .cloned()
3227        .collect();
3228    let mut config = parent.effective_configuration.clone();
3229    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
3230    AnalysisRun {
3231        tool: parent.tool.clone(),
3232        environment: parent.environment.clone(),
3233        effective_configuration: config,
3234        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
3235        summary_totals: SummaryTotals {
3236            files_considered: sub.files_analyzed,
3237            files_analyzed: sub.files_analyzed,
3238            files_skipped: 0,
3239            total_physical_lines: sub.total_physical_lines,
3240            code_lines: sub.code_lines,
3241            comment_lines: sub.comment_lines,
3242            blank_lines: sub.blank_lines,
3243            mixed_lines_separate: 0,
3244            functions: 0,
3245            classes: 0,
3246            variables: 0,
3247            imports: 0,
3248        },
3249        totals_by_language: sub.language_summaries.clone(),
3250        per_file_records: sub_files,
3251        skipped_file_records: vec![],
3252        warnings: vec![],
3253        submodule_summaries: vec![],
3254        git_commit_short: parent.git_commit_short.clone(),
3255        git_commit_long: parent.git_commit_long.clone(),
3256        git_branch: parent.git_branch.clone(),
3257        git_commit_author: parent.git_commit_author.clone(),
3258        git_tags: parent.git_tags.clone(),
3259    }
3260}
3261
3262fn sanitize_project_label(raw: &str) -> String {
3263    let candidate = Path::new(raw)
3264        .file_name()
3265        .and_then(|name| name.to_str())
3266        .unwrap_or("project");
3267
3268    let mut value = String::with_capacity(candidate.len());
3269    for ch in candidate.chars() {
3270        if ch.is_ascii_alphanumeric() {
3271            value.push(ch.to_ascii_lowercase());
3272        } else {
3273            value.push('-');
3274        }
3275    }
3276
3277    let compact = value.trim_matches('-').to_string();
3278    if compact.is_empty() {
3279        "project".to_string()
3280    } else {
3281        compact
3282    }
3283}
3284
3285/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
3286/// comparisons with non-canonicalized stored paths work correctly.
3287fn strip_unc_prefix(path: PathBuf) -> PathBuf {
3288    let s = path.to_string_lossy();
3289    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
3290        return PathBuf::from(format!(r"\\{rest}"));
3291    }
3292    if let Some(rest) = s.strip_prefix(r"\\?\") {
3293        return PathBuf::from(rest);
3294    }
3295    path
3296}
3297
3298fn display_path(path: &Path) -> String {
3299    let s = path.to_string_lossy();
3300    // Strip Windows extended-length prefix for display only; the underlying
3301    // PathBuf remains unchanged so file operations are unaffected.
3302    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
3303    // \\?\C:\path           →  C:\path          (local drive)
3304    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
3305        return format!(r"\\{rest}");
3306    }
3307    if let Some(rest) = s.strip_prefix(r"\\?\") {
3308        return rest.to_owned();
3309    }
3310    s.into_owned()
3311}
3312
3313fn sanitize_path_str(s: &str) -> String {
3314    // Forward-slash variants of the Windows extended-length prefix that appear
3315    // when paths stored as plain strings have been processed through some path
3316    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
3317    if let Some(rest) = s.strip_prefix("//?/UNC/") {
3318        return format!("//{rest}");
3319    }
3320    if let Some(rest) = s.strip_prefix("//?/") {
3321        return rest.to_owned();
3322    }
3323    display_path(Path::new(s))
3324}
3325
3326fn workspace_root() -> PathBuf {
3327    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
3328    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
3329        let p = PathBuf::from(root);
3330        if p.is_dir() {
3331            return p;
3332        }
3333    }
3334
3335    // Binary's parent directory — works when the binary is co-located with
3336    // docs/assets/, regardless of where the project lives.
3337    // env!("CARGO_MANIFEST_DIR") bakes the compile-time path into the binary,
3338    // which breaks on any machine other than the one that compiled it.
3339    if let Ok(exe) = std::env::current_exe() {
3340        if let Some(dir) = exe.parent() {
3341            if dir.join("docs").join("assets").is_dir() {
3342                return dir.to_path_buf();
3343            }
3344        }
3345    }
3346
3347    // Current working directory — works for `cargo run` invocations launched
3348    // from the project root, and for scripts/run.sh which cds there first.
3349    if let Ok(cwd) = std::env::current_dir() {
3350        if cwd.join("docs").join("assets").is_dir() {
3351            return cwd;
3352        }
3353    }
3354
3355    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
3356}
3357
3358fn resolve_input_path(raw: &str) -> PathBuf {
3359    let trimmed = raw.trim();
3360    if trimmed.is_empty() {
3361        return workspace_root().join("samples").join("basic");
3362    }
3363
3364    let candidate = PathBuf::from(trimmed);
3365    let resolved = if candidate.is_absolute() {
3366        candidate
3367    } else {
3368        let rooted = workspace_root().join(&candidate);
3369        if rooted.exists() {
3370            rooted
3371        } else {
3372            workspace_root().join(candidate)
3373        }
3374    };
3375
3376    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
3377    // strip that prefix so stored paths and the displayed "Project path" are clean.
3378    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
3379    PathBuf::from(display_path(&canonical))
3380}
3381
3382#[allow(clippy::too_many_lines)]
3383fn build_preview_html(
3384    root: &Path,
3385    include_patterns: &[String],
3386    exclude_patterns: &[String],
3387) -> Result<String> {
3388    if !root.exists() {
3389        return Ok(format!(
3390            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
3391            escape_html(&display_path(root))
3392        ));
3393    }
3394
3395    let _selected = display_path(root);
3396    let mut stats = PreviewStats::default();
3397    let mut rows = Vec::new();
3398    let mut languages = Vec::new();
3399    let mut budget = PreviewBudget {
3400        shown: 0,
3401        max_entries: 600,
3402        max_depth: 9,
3403    };
3404    let mut next_row_id = 1usize;
3405
3406    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
3407        || root.to_string_lossy().into_owned(),
3408        std::string::ToString::to_string,
3409    );
3410    let root_modified = root
3411        .metadata()
3412        .ok()
3413        .and_then(|meta| meta.modified().ok())
3414        .map_or_else(|| "-".to_string(), format_system_time);
3415
3416    rows.push(PreviewRow {
3417        row_id: 0,
3418        parent_row_id: None,
3419        depth: 0,
3420        name: format!("{root_name}/"),
3421        kind: PreviewKind::Dir,
3422        is_dir: true,
3423        language: None,
3424        modified: root_modified,
3425        type_label: "Directory".to_string(),
3426    });
3427    collect_preview_rows(
3428        root,
3429        root,
3430        0,
3431        Some(0),
3432        &mut next_row_id,
3433        &mut budget,
3434        &mut stats,
3435        &mut rows,
3436        &mut languages,
3437        include_patterns,
3438        exclude_patterns,
3439    )?;
3440
3441    let mut out = String::new();
3442    out.push_str(r#"<div class="explorer-wrap">"#);
3443    out.push_str(r#"<div class="explorer-toolbar compact">"#);
3444    out.push_str(r#"<div class="explorer-title-group">"#);
3445    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
3446    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
3447    out.push_str(r"</div></div>");
3448
3449    out.push_str(r#"<div class="scope-stats">"#);
3450    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();
3451    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();
3452    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();
3453    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();
3454    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();
3455    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>"#);
3456    out.push_str(r"</div>");
3457
3458    out.push_str(r#"<div class="scope-info-row">"#);
3459    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
3460    if languages.is_empty() {
3461        out.push_str(
3462            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
3463        );
3464    } else {
3465        out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
3466        for language in &languages {
3467            if let Some(icon) = language_icon_file(language) {
3468                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();
3469            } else if let Some(svg) = language_inline_svg(language) {
3470                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();
3471            } else {
3472                write!(
3473                    out,
3474                    r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
3475                    escape_html(&language.to_ascii_lowercase()),
3476                    escape_html(language)
3477                )
3478                .ok();
3479            }
3480        }
3481    }
3482    out.push_str(r"</div></div>");
3483    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>"#);
3484    out.push_str(r"</div>");
3485
3486    out.push_str(r#"<div class="file-explorer-shell">"#);
3487    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>"#);
3488    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>"#);
3489    out.push_str(r#"<div class="file-explorer-tree">"#);
3490    for row in rows {
3491        let status_label = row.kind.label();
3492        let lang_attr = row.language.unwrap_or("");
3493        let toggle_html = if row.is_dir {
3494            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
3495                .to_string()
3496        } else {
3497            r#"<span class="tree-bullet">•</span>"#.to_string()
3498        };
3499        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();
3500    }
3501    if budget.shown >= budget.max_entries {
3502        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>"#);
3503    }
3504    out.push_str(r"</div></div></div>");
3505
3506    Ok(out)
3507}
3508
3509#[derive(Default)]
3510struct PreviewStats {
3511    directories: usize,
3512    files: usize,
3513    supported: usize,
3514    skipped: usize,
3515    unsupported: usize,
3516}
3517
3518struct PreviewRow {
3519    row_id: usize,
3520    parent_row_id: Option<usize>,
3521    depth: usize,
3522    name: String,
3523    kind: PreviewKind,
3524    is_dir: bool,
3525    language: Option<&'static str>,
3526    modified: String,
3527    type_label: String,
3528}
3529
3530#[derive(Copy, Clone)]
3531enum PreviewKind {
3532    Dir,
3533    Supported,
3534    Skipped,
3535    Unsupported,
3536}
3537
3538impl PreviewKind {
3539    const fn filter_key(self) -> &'static str {
3540        match self {
3541            Self::Dir => "dir",
3542            Self::Supported => "supported",
3543            Self::Skipped => "skipped",
3544            Self::Unsupported => "unsupported",
3545        }
3546    }
3547
3548    const fn label(self) -> &'static str {
3549        match self {
3550            Self::Dir => "dir",
3551            Self::Supported => "supported",
3552            Self::Skipped => "skipped by policy",
3553            Self::Unsupported => "unsupported",
3554        }
3555    }
3556
3557    const fn badge_class(self) -> &'static str {
3558        match self {
3559            Self::Dir => "badge badge-dir",
3560            Self::Supported => "badge badge-scan",
3561            Self::Skipped => "badge badge-skip",
3562            Self::Unsupported => "badge badge-unsupported",
3563        }
3564    }
3565
3566    const fn node_class(self) -> &'static str {
3567        match self {
3568            Self::Dir => "tree-node-dir",
3569            Self::Supported => "tree-node-supported",
3570            Self::Skipped => "tree-node-skipped",
3571            Self::Unsupported => "tree-node-unsupported",
3572        }
3573    }
3574}
3575
3576struct PreviewBudget {
3577    shown: usize,
3578    max_entries: usize,
3579    max_depth: usize,
3580}
3581
3582/// Handle a single directory entry inside `collect_preview_rows`.
3583/// Returns `true` when the entry was handled (caller should `continue`).
3584#[allow(clippy::too_many_arguments)]
3585fn handle_preview_dir_entry(
3586    root: &Path,
3587    path: &Path,
3588    name: &str,
3589    modified: String,
3590    depth: usize,
3591    parent_row_id: Option<usize>,
3592    row_id: usize,
3593    next_row_id: &mut usize,
3594    budget: &mut PreviewBudget,
3595    stats: &mut PreviewStats,
3596    rows: &mut Vec<PreviewRow>,
3597    languages: &mut Vec<&'static str>,
3598    include_patterns: &[String],
3599    exclude_patterns: &[String],
3600) -> Result<()> {
3601    let relative = preview_relative_path(root, path);
3602    if should_skip_preview_directory(&relative, exclude_patterns) {
3603        return Ok(());
3604    }
3605    stats.directories += 1;
3606    rows.push(PreviewRow {
3607        row_id,
3608        parent_row_id,
3609        depth: depth + 1,
3610        name: format!("{name}/"),
3611        kind: PreviewKind::Dir,
3612        is_dir: true,
3613        language: None,
3614        modified,
3615        type_label: "Directory".to_string(),
3616    });
3617    budget.shown += 1;
3618    if !matches!(name, ".git" | "node_modules" | "target") {
3619        collect_preview_rows(
3620            root,
3621            path,
3622            depth + 1,
3623            Some(row_id),
3624            next_row_id,
3625            budget,
3626            stats,
3627            rows,
3628            languages,
3629            include_patterns,
3630            exclude_patterns,
3631        )?;
3632    }
3633    Ok(())
3634}
3635
3636/// Handle a single file entry inside `collect_preview_rows`.
3637#[allow(clippy::too_many_arguments)]
3638fn handle_preview_file_entry(
3639    root: &Path,
3640    path: &Path,
3641    name: &str,
3642    modified: String,
3643    depth: usize,
3644    parent_row_id: Option<usize>,
3645    row_id: usize,
3646    budget: &mut PreviewBudget,
3647    stats: &mut PreviewStats,
3648    rows: &mut Vec<PreviewRow>,
3649    languages: &mut Vec<&'static str>,
3650    include_patterns: &[String],
3651    exclude_patterns: &[String],
3652) {
3653    let relative = preview_relative_path(root, path);
3654    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
3655        return;
3656    }
3657    stats.files += 1;
3658    let kind = classify_preview_file(name);
3659    match kind {
3660        PreviewKind::Supported => stats.supported += 1,
3661        PreviewKind::Skipped => stats.skipped += 1,
3662        PreviewKind::Unsupported => stats.unsupported += 1,
3663        PreviewKind::Dir => {}
3664    }
3665    let language = detect_language_name(name);
3666    if let Some(lang) = language {
3667        if !languages.contains(&lang) {
3668            languages.push(lang);
3669        }
3670    }
3671    rows.push(PreviewRow {
3672        row_id,
3673        parent_row_id,
3674        depth: depth + 1,
3675        name: name.to_owned(),
3676        kind,
3677        is_dir: false,
3678        language,
3679        modified,
3680        type_label: preview_type_label(name, language, kind),
3681    });
3682    budget.shown += 1;
3683}
3684
3685#[allow(clippy::too_many_arguments)]
3686#[allow(clippy::too_many_lines)]
3687fn collect_preview_rows(
3688    root: &Path,
3689    dir: &Path,
3690    depth: usize,
3691    parent_row_id: Option<usize>,
3692    next_row_id: &mut usize,
3693    budget: &mut PreviewBudget,
3694    stats: &mut PreviewStats,
3695    rows: &mut Vec<PreviewRow>,
3696    languages: &mut Vec<&'static str>,
3697    include_patterns: &[String],
3698    exclude_patterns: &[String],
3699) -> Result<()> {
3700    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
3701        return Ok(());
3702    }
3703
3704    let mut entries = fs::read_dir(dir)
3705        .with_context(|| format!("failed to read directory {}", dir.display()))?
3706        .filter_map(std::result::Result::ok)
3707        .collect::<Vec<_>>();
3708    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
3709
3710    for entry in entries {
3711        if budget.shown >= budget.max_entries {
3712            break;
3713        }
3714
3715        let path = entry.path();
3716        let name = entry.file_name().to_string_lossy().into_owned();
3717        let Ok(metadata) = entry.metadata() else {
3718            continue;
3719        };
3720        let row_id = *next_row_id;
3721        *next_row_id += 1;
3722        let modified = metadata
3723            .modified()
3724            .ok()
3725            .map_or_else(|| "-".to_string(), format_system_time);
3726
3727        if metadata.is_dir() {
3728            handle_preview_dir_entry(
3729                root,
3730                &path,
3731                &name,
3732                modified,
3733                depth,
3734                parent_row_id,
3735                row_id,
3736                next_row_id,
3737                budget,
3738                stats,
3739                rows,
3740                languages,
3741                include_patterns,
3742                exclude_patterns,
3743            )?;
3744            continue;
3745        }
3746
3747        if metadata.is_file() {
3748            handle_preview_file_entry(
3749                root,
3750                &path,
3751                &name,
3752                modified,
3753                depth,
3754                parent_row_id,
3755                row_id,
3756                budget,
3757                stats,
3758                rows,
3759                languages,
3760                include_patterns,
3761                exclude_patterns,
3762            );
3763        }
3764    }
3765
3766    Ok(())
3767}
3768
3769fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
3770    if let Some(language) = language {
3771        return format!("{language} source");
3772    }
3773    let lower = name.to_ascii_lowercase();
3774    let ext = Path::new(&lower)
3775        .extension()
3776        .and_then(|e| e.to_str())
3777        .unwrap_or("");
3778    match kind {
3779        PreviewKind::Skipped => {
3780            if lower.ends_with(".min.js") {
3781                "Minified asset".to_string()
3782            } else if [
3783                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
3784            ]
3785            .contains(&ext)
3786            {
3787                "Binary or archive".to_string()
3788            } else {
3789                "Skipped file".to_string()
3790            }
3791        }
3792        PreviewKind::Unsupported => {
3793            if ext.is_empty() {
3794                "Unsupported file".to_string()
3795            } else {
3796                format!("{} file", ext.to_ascii_uppercase())
3797            }
3798        }
3799        PreviewKind::Supported => "Supported source".to_string(),
3800        PreviewKind::Dir => "Directory".to_string(),
3801    }
3802}
3803
3804fn format_system_time(time: SystemTime) -> String {
3805    #[allow(clippy::cast_possible_wrap)]
3806    let secs = match time.duration_since(UNIX_EPOCH) {
3807        Ok(duration) => duration.as_secs() as i64,
3808        Err(_) => return "-".to_string(),
3809    };
3810    let days = secs.div_euclid(86_400);
3811    let secs_of_day = secs.rem_euclid(86_400);
3812    let (year, month, day) = civil_from_days(days);
3813    let hour = secs_of_day / 3_600;
3814    let minute = (secs_of_day % 3_600) / 60;
3815    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
3816}
3817
3818#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3819fn civil_from_days(days: i64) -> (i32, u32, u32) {
3820    let z = days + 719_468;
3821    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
3822    let doe = z - era * 146_097;
3823    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
3824    let y = yoe + era * 400;
3825    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3826    let mp = (5 * doy + 2) / 153;
3827    let d = doy - (153 * mp + 2) / 5 + 1;
3828    let m = mp + if mp < 10 { 3 } else { -9 };
3829    let year = y + i64::from(m <= 2);
3830    (year as i32, m as u32, d as u32)
3831}
3832
3833// The input is already lowercased via `to_ascii_lowercase()` before calling
3834// `ends_with`, so the comparisons are inherently case-insensitive.
3835#[allow(clippy::case_sensitive_file_extension_comparisons)]
3836fn detect_language_name(name: &str) -> Option<&'static str> {
3837    let lower = name.to_ascii_lowercase();
3838    if lower.ends_with(".c") || lower.ends_with(".h") {
3839        Some("C")
3840    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
3841        .iter()
3842        .any(|s| lower.ends_with(s))
3843    {
3844        Some("C++")
3845    } else if lower.ends_with(".cs") {
3846        Some("C#")
3847    } else if lower.ends_with(".py") {
3848        Some("Python")
3849    } else if lower.ends_with(".sh") {
3850        Some("Shell")
3851    } else if [".ps1", ".psm1", ".psd1"]
3852        .iter()
3853        .any(|s| lower.ends_with(s))
3854    {
3855        Some("PowerShell")
3856    } else {
3857        None
3858    }
3859}
3860
3861fn language_icon_file(language: &str) -> Option<&'static str> {
3862    match language {
3863        "C" => Some("c.png"),
3864        "C++" => Some("cpp.png"),
3865        "C#" => Some("c-sharp.png"),
3866        "Python" => Some("python.png"),
3867        "Shell" => Some("shell.png"),
3868        "PowerShell" => Some("powershell.png"),
3869        "JavaScript" => Some("java-script.png"),
3870        "HTML" => Some("html-5.png"),
3871        "Java" => Some("java.png"),
3872        "Visual Basic" => Some("visual-basic.png"),
3873        _ => None,
3874    }
3875}
3876
3877// Inline SVG badges for languages that have no PNG icon in images/icons/.
3878// Using inline SVG keeps the web UI fully self-contained — no extra files
3879// needed on disk, no 404s on air-gapped deployments.
3880// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
3881fn language_inline_svg(language: &str) -> Option<&'static str> {
3882    match language {
3883        "Go" => Some(
3884            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="#00ACD7"/><text x="50" y="68" text-anchor="middle" font-family="sans-serif" font-weight="900" font-size="46" fill="#fff">Go</text></svg>"##,
3885        ),
3886        "Rust" => Some(
3887            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>"##,
3888        ),
3889        "TypeScript" => Some(
3890            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>"##,
3891        ),
3892        _ => None,
3893    }
3894}
3895
3896// The input is already lowercased via `to_ascii_lowercase()` before the
3897// `ends_with` calls, so these comparisons are inherently case-insensitive.
3898#[allow(clippy::case_sensitive_file_extension_comparisons)]
3899fn classify_preview_file(name: &str) -> PreviewKind {
3900    let lower = name.to_ascii_lowercase();
3901
3902    let scannable = [
3903        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
3904        ".psm1", ".psd1",
3905    ]
3906    .iter()
3907    .any(|suffix| lower.ends_with(suffix));
3908
3909    if scannable {
3910        PreviewKind::Supported
3911    } else if lower.ends_with(".min.js")
3912        || lower.ends_with(".lock")
3913        || lower.ends_with(".png")
3914        || lower.ends_with(".jpg")
3915        || lower.ends_with(".jpeg")
3916        || lower.ends_with(".gif")
3917        || lower.ends_with(".zip")
3918        || lower.ends_with(".pdf")
3919        || lower.ends_with(".pyc")
3920        || lower.ends_with(".xz")
3921        || lower.ends_with(".tar")
3922        || lower.ends_with(".gz")
3923    {
3924        PreviewKind::Skipped
3925    } else {
3926        PreviewKind::Unsupported
3927    }
3928}
3929
3930fn preview_relative_path(root: &Path, path: &Path) -> String {
3931    path.strip_prefix(root)
3932        .ok()
3933        .unwrap_or(path)
3934        .to_string_lossy()
3935        .replace('\\', "/")
3936        .trim_matches('/')
3937        .to_string()
3938}
3939
3940fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
3941    if relative.is_empty() {
3942        return false;
3943    }
3944
3945    exclude_patterns.iter().any(|pattern| {
3946        wildcard_match(pattern, relative)
3947            || wildcard_match(pattern, &format!("{relative}/"))
3948            || wildcard_match(pattern, &format!("{relative}/placeholder"))
3949    })
3950}
3951
3952fn should_include_preview_file(
3953    relative: &str,
3954    include_patterns: &[String],
3955    exclude_patterns: &[String],
3956) -> bool {
3957    if relative.is_empty() {
3958        return true;
3959    }
3960
3961    let included = include_patterns.is_empty()
3962        || include_patterns
3963            .iter()
3964            .any(|pattern| wildcard_match(pattern, relative));
3965    let excluded = exclude_patterns
3966        .iter()
3967        .any(|pattern| wildcard_match(pattern, relative));
3968
3969    included && !excluded
3970}
3971
3972fn wildcard_match(pattern: &str, candidate: &str) -> bool {
3973    let pattern = pattern.trim().replace('\\', "/");
3974    let candidate = candidate.trim().replace('\\', "/");
3975    let p = pattern.as_bytes();
3976    let c = candidate.as_bytes();
3977    let mut pi = 0usize;
3978    let mut ci = 0usize;
3979    let mut star: Option<usize> = None;
3980    let mut star_match = 0usize;
3981
3982    while ci < c.len() {
3983        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
3984            pi += 1;
3985            ci += 1;
3986        } else if pi < p.len() && p[pi] == b'*' {
3987            while pi < p.len() && p[pi] == b'*' {
3988                pi += 1;
3989            }
3990            star = Some(pi);
3991            star_match = ci;
3992        } else if let Some(star_pi) = star {
3993            star_match += 1;
3994            ci = star_match;
3995            pi = star_pi;
3996        } else {
3997            return false;
3998        }
3999    }
4000
4001    while pi < p.len() && p[pi] == b'*' {
4002        pi += 1;
4003    }
4004
4005    pi == p.len()
4006}
4007
4008fn escape_html(value: &str) -> String {
4009    value
4010        .replace('&', "&amp;")
4011        .replace('<', "&lt;")
4012        .replace('>', "&gt;")
4013        .replace('"', "&quot;")
4014        .replace('\'', "&#39;")
4015}
4016
4017#[derive(Clone)]
4018struct LanguageSummaryRow {
4019    language: String,
4020    files: u64,
4021    physical: u64,
4022    code: u64,
4023    comments: u64,
4024    blank: u64,
4025    mixed: u64,
4026    functions: u64,
4027    classes: u64,
4028    variables: u64,
4029    imports: u64,
4030}
4031
4032#[derive(Clone)]
4033struct SubmoduleRow {
4034    name: String,
4035    relative_path: String,
4036    files_analyzed: u64,
4037    code_lines: u64,
4038    comment_lines: u64,
4039    blank_lines: u64,
4040    total_physical_lines: u64,
4041    html_url: Option<String>,
4042}
4043
4044#[derive(Template)]
4045#[template(
4046    source = r##"
4047<!doctype html>
4048<html lang="en">
4049<head>
4050  <meta charset="utf-8">
4051  <title>OxideSLOC | samples/basic</title>
4052  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
4053  <style nonce="{{ csp_nonce }}">
4054    :root {
4055      --bg: #efe9e2;
4056      --surface: #fcfaf7;
4057      --surface-2: #f7f0e8;
4058      --surface-3: #efe3d5;
4059      --line: #dfcfbf;
4060      --line-strong: #cfb29c;
4061      --text: #2f241c;
4062      --muted: #6f6257;
4063      --muted-2: #917f71;
4064      --nav: #b85d33;
4065      --nav-2: #7a371b;
4066      --accent: #2563eb;
4067      --accent-2: #1d4ed8;
4068      --oxide: #b85d33;
4069      --oxide-2: #8f4220;
4070      --success-bg: #eaf9ee;
4071      --success-text: #1c8746;
4072      --warn-bg: #fff2d8;
4073      --warn-text: #926000;
4074      --danger-bg: #fdeaea;
4075      --danger-text: #b33b3b;
4076      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
4077      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
4078      --radius: 14px;
4079    }
4080
4081    body.dark-theme {
4082      --bg: #1b1511;
4083      --surface: #261c17;
4084      --surface-2: #2d221d;
4085      --surface-3: #372922;
4086      --line: #524238;
4087      --line-strong: #6c5649;
4088      --text: #f5ece6;
4089      --muted: #c7b7aa;
4090      --muted-2: #aa9485;
4091      --nav: #b85d33;
4092      --nav-2: #7a371b;
4093      --accent: #6f9bff;
4094      --accent-2: #4a78ee;
4095      --oxide: #d37a4c;
4096      --oxide-2: #b35428;
4097      --success-bg: #163927;
4098      --success-text: #8fe2a8;
4099      --warn-bg: #3c2d11;
4100      --warn-text: #f3cb75;
4101      --danger-bg: #3d1f1f;
4102      --danger-text: #ff9f9f;
4103      --shadow: 0 14px 28px rgba(0,0,0,0.28);
4104      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
4105    }
4106
4107    * { box-sizing: border-box; }
4108    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); }
4109    html { overflow-y: scroll; }
4110    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
4111    .top-nav, .page, .loading { position: relative; z-index: 2; }
4112    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4113    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
4114    .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); }
4115    .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; }
4116    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
4117    .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)); }
4118    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
4119    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
4120    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
4121    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
4122    .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; }
4123    .nav-project-pill.visible { display:inline-flex; }
4124    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
4125    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
4126    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: wrap; }
4127    .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; }
4128    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
4129    .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; }
4130    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
4131    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
4132    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
4133    .theme-toggle .icon-sun { display:none; }
4134    body.dark-theme .theme-toggle .icon-sun { display:block; }
4135    body.dark-theme .theme-toggle .icon-moon { display:none; }
4136    .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; }
4137    .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;}
4138    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
4139    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
4140    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
4141    .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
4142    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
4143    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; }
4144    .wb-stats-header { padding: 10px 24px 0; }
4145    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
4146    .ws-left { display:flex; align-items:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
4147    .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); }
4148    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
4149    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
4150    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
4151    .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; }
4152    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
4153    .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; }
4154    .ws-badge:hover .ws-lang-tooltip { display:block; }
4155    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:9px; }
4156    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
4157    .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; }
4158    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
4159    .ws-divider { display: none; }
4160    .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%; }
4161    .ws-path-link:hover { color:var(--oxide); }
4162    body.dark-theme .ws-path-link { color:var(--oxide); }
4163    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
4164    .ws-stat-output .ws-value { overflow:hidden; display:block; }
4165    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
4166    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
4167    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
4168    .ws-mini-box-lg { flex:2 1 0; }
4169    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
4170    .ws-mini-box-br { flex:1.5 1 0; }
4171    .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); }
4172    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
4173    .path-scope-grid { display:grid; grid-template-columns: 1fr 1px auto; gap:0; align-items:stretch; }
4174    .path-scope-grid .input-group { width:100%; align-self:start; }
4175    .path-scope-sep { background:var(--line); margin:4px 14px; }
4176    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
4177    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
4178    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
4179    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
4180    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
4181    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
4182    .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; }
4183    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
4184    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
4185    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
4186    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
4187    .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; }
4188    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
4189    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
4190    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
4191    .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; }
4192    .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); }
4193    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
4194    .side-info-card { padding: 18px; }
4195    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
4196    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
4197    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
4198    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
4199    .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); }
4200    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
4201    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
4202    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
4203    .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; }
4204    .layout { display:grid; grid-template-columns: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
4205    .layout[data-active-step="4"] { align-items: start; min-height: auto; }
4206    .side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; width: 218px; max-width: 218px; }
4207    .step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
4208    .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); }
4209    .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; }
4210    .step-button:hover { background: var(--surface-2); }
4211    .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); }
4212    .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; }
4213    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
4214    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
4215    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
4216    .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); }
4217    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
4218    .step-nav-sum-row:last-child { border-bottom:none; }
4219    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
4220    .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; }
4221    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
4222    .quick-scan-section { padding: 10px 4px 14px; }
4223    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
4224    .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; }
4225    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
4226    .quick-scan-btn:active { transform:translateY(0); }
4227    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
4228    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
4229    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
4230    @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);} }
4231    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
4232    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
4233    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
4234    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
4235    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
4236    .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; }
4237    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
4238    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
4239    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
4240    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
4241    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
4242    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
4243    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
4244    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
4245    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
4246    .card-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
4247    .card-body { padding: 22px; }
4248    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
4249    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
4250    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
4251    .section { margin-bottom: 22px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
4252    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
4253    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
4254    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
4255    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
4256    .field { min-width:0; }
4257    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
4258    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; }
4259    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); }
4260    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
4261    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); }
4262    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
4263    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
4264    .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; }
4265    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
4266    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
4267    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
4268    .input-group.compact { grid-template-columns: 1fr auto auto; }
4269    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
4270    .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)); }
4271    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
4272    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
4273    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
4274    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
4275    .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; }
4276    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
4277    .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; }
4278    .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); }
4279    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
4280    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
4281    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
4282    button.secondary { background: var(--surface); }
4283    .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); }
4284    .section + .wizard-actions { border-top: none; padding-top: 0; }
4285    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
4286    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
4287    .field-help-grid.coupled-help { margin-top: 12px; }
4288    .field-help-grid.preset-grid { align-items: start; }
4289    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:stretch; margin-bottom: 16px; }
4290    .preset-inline-row .field { margin: 0; }
4291    .preset-inline-row .explainer-card { margin: 0; }
4292    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
4293    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
4294    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
4295    .output-field-row .field { margin: 0; }
4296    .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; }
4297    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
4298    .step3-subtitle { margin-bottom: 28px; }
4299    .counting-intro { margin-bottom: 22px; max-width: none; }
4300    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
4301    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
4302    .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; }
4303    .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; }
4304    .section-spacer-top { margin-top: 28px; }
4305    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
4306    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
4307    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
4308    .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); }
4309    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
4310    .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; }
4311    .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; }
4312    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
4313    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
4314    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
4315    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
4316    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
4317    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
4318    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
4319    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; }
4320    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
4321    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
4322    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
4323    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
4324    .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); }
4325    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
4326    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
4327    .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; }
4328    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
4329    .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; }
4330    .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; }
4331    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
4332    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
4333    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
4334    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
4335    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
4336    .advanced-rule-description strong { color: var(--text); }
4337    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
4338    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
4339    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
4340    .review-link:hover { text-decoration: underline; }
4341    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
4342    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
4343    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
4344    .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; }
4345    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
4346    .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; }
4347    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
4348    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
4349    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
4350    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
4351    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
4352    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
4353    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
4354    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
4355    .review-card ul { padding-left: 18px; margin: 0; }
4356    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
4357    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
4358    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
4359    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
4360    .review-card { min-height: 200px; }
4361    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
4362    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
4363    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
4364    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
4365    .lang-overflow-chip { position:relative; cursor:default; }
4366    .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; }
4367    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
4368    .git-inline-row { align-items:start; }
4369    .mixed-line-card { display:flex; flex-direction:column; }
4370    .preset-inline-row .toggle-card { justify-content: center; }
4371        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
4372    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
4373    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
4374    .explorer-title { font-size: 18px; font-weight: 850; }
4375    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
4376    .explorer-subtitle.wide { max-width: none; }
4377    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
4378    .better-spacing { align-items:flex-start; justify-content:flex-end; }
4379    .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; }
4380    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
4381    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
4382    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
4383    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
4384    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
4385    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
4386    .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; }
4387    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
4388    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
4389    .scope-stat-button.supported { background: var(--success-bg); }
4390    .scope-stat-button.skipped { background: var(--warn-bg); }
4391    .scope-stat-button.unsupported { background: var(--danger-bg); }
4392    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
4393    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
4394    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
4395    [data-tooltip] { position: relative; }
4396    [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); }
4397    [data-tooltip]:hover::after { display: block; }
4398    .scope-stat-button[data-tooltip] { cursor: pointer; }
4399    .badge[data-tooltip] { cursor: help; }
4400    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
4401    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
4402    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
4403    .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; }
4404    .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; }
4405    code { display:inline-block; margin-top:0; padding:2px 7px; }
4406    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
4407    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
4408    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
4409    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
4410    .language-pill.muted-pill { color: var(--muted); }
4411    button.language-pill { appearance:none; cursor:pointer; }
4412    .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); }
4413    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
4414    .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; }
4415    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
4416    .file-explorer-search-row { margin-left: auto; }
4417    .explorer-filter-select { min-width: 170px; width: 170px; }
4418    .explorer-search { min-width: 300px; width: 300px; }
4419    .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); }
4420    .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; }
4421    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
4422    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
4423    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
4424    .file-explorer-tree { max-height: 560px; overflow:auto; }
4425    .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); }
4426    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
4427    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
4428    .tree-row.hidden-by-filter { display:none !important; }
4429    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 9px 0; }
4430    .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; }
4431    .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; }
4432    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
4433    .tree-bullet { color: var(--muted-2); width: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
4434    .tree-node { display:inline-flex; align-items:center; min-width:0; }
4435    .tree-node-dir { color: var(--text); font-weight: 800; }
4436    .tree-node-supported { color: var(--success-text); }
4437    .tree-node-skipped { color: var(--warn-text); }
4438    .tree-node-unsupported { color: var(--danger-text); }
4439    .tree-node-more { color: var(--muted-2); font-style: italic; }
4440    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 13px; }
4441    .tree-status-cell { display:flex; justify-content:flex-start; }
4442    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
4443    .loading { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; background: rgba(17,24,39,0.28); z-index: 100; }
4444    .loading.active { display:flex; }
4445    .loading-card { width: min(540px, calc(100vw - 40px)); border-radius: 18px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 20px 40px rgba(0,0,0,0.18); padding: 24px; text-align:center; }
4446    .spinner { width:44px; height:44px; margin:0 auto 16px; border-radius:999px; border:4px solid rgba(0,0,0,0.10); border-top-color: var(--accent); animation: spin .9s linear infinite; }
4447    @keyframes spin { to { transform: rotate(360deg);} }
4448    .progress-bar { width:100%; height:8px; margin-top:14px; background: var(--surface-3); border-radius:999px; overflow:hidden; }
4449    .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent), #6b8cff); animation: pulseBar 1.4s ease-in-out infinite; }
4450    @keyframes pulseBar { 0% { transform: translateX(-35%); width:25%; } 50% { transform: translateX(130%); width:44%; } 100% { transform: translateX(250%); width:25%; } }
4451    .hidden { display:none !important; }
4452    .site-footer { position: relative; z-index: 2; margin-top: 24px; padding: 20px 24px; border-top: 1px solid var(--line); background: rgba(0,0,0,0.04); text-align: center; color: var(--muted); font-size: 13px; line-height: 1.7; }
4453    .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
4454    .site-footer a:hover { color: var(--text); text-decoration: underline; }
4455    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
4456    @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; } }
4457    .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;}
4458    @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));}}
4459  </style>
4460</head>
4461<body>
4462  <div class="background-watermarks" aria-hidden="true">
4463    <img src="/images/logo/logo-text.png" alt="" />
4464    <img src="/images/logo/logo-text.png" alt="" />
4465    <img src="/images/logo/logo-text.png" alt="" />
4466    <img src="/images/logo/logo-text.png" alt="" />
4467    <img src="/images/logo/logo-text.png" alt="" />
4468    <img src="/images/logo/logo-text.png" alt="" />
4469    <img src="/images/logo/logo-text.png" alt="" />
4470    <img src="/images/logo/logo-text.png" alt="" />
4471    <img src="/images/logo/logo-text.png" alt="" />
4472    <img src="/images/logo/logo-text.png" alt="" />
4473    <img src="/images/logo/logo-text.png" alt="" />
4474    <img src="/images/logo/logo-text.png" alt="" />
4475    <img src="/images/logo/logo-text.png" alt="" />
4476    <img src="/images/logo/logo-text.png" alt="" />
4477  </div>
4478  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
4479  <div class="top-nav">
4480    <div class="top-nav-inner">
4481      <a class="brand" href="/">
4482        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
4483        <div class="brand-copy">
4484          <div class="brand-title">OxideSLOC</div>
4485          <div class="brand-subtitle">Local analysis workbench</div>
4486        </div>
4487      </a>
4488      <div class="nav-project-slot">
4489        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
4490          <span class="nav-project-label">Project</span>
4491          <span class="nav-project-value" id="nav-project-title">samples/basic</span>
4492        </div>
4493      </div>
4494      <div class="nav-status">
4495        <a class="nav-pill" href="/">Home</a>
4496        <a class="nav-pill" href="/view-reports">View Reports</a>
4497        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
4498        <div class="server-status-wrap">
4499          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
4500          <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>
4501        </div>
4502        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
4503          <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>
4504          <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>
4505        </button>
4506      </div>
4507    </div>
4508  </div>
4509
4510  <div class="loading" id="loading">
4511    <div class="loading-card">
4512      <div class="spinner"></div>
4513      <h2>Scanning project...</h2>
4514      <p>This build still performs web scans synchronously. For very large repositories, keep this tab open while the Rust analysis core completes the run.</p>
4515      <div class="progress-bar"><span></span></div>
4516    </div>
4517  </div>
4518
4519  <div class="page">
4520    <div class="workbench-strip">
4521      <div class="workbench-box wb-stats">
4522        <div class="wb-stats-header">
4523          <span class="wb-stats-title">Analysis session</span>
4524        </div>
4525        <div class="ws-left">
4526          <div class="ws-stat">
4527            <span class="ws-label">Analyzers</span>
4528            <span class="ws-value">
4529              <span class="ws-badge">41 languages
4530                <div class="ws-lang-tooltip">
4531                  <div class="ws-lang-tooltip-hdr">41 supported languages</div>
4532                  <div class="ws-lang-grid">
4533                    <span class="ws-lang-item">Assembly</span>
4534                    <span class="ws-lang-item">C</span>
4535                    <span class="ws-lang-item">C++</span>
4536                    <span class="ws-lang-item">C#</span>
4537                    <span class="ws-lang-item">Clojure</span>
4538                    <span class="ws-lang-item">CSS</span>
4539                    <span class="ws-lang-item">Dart</span>
4540                    <span class="ws-lang-item">Dockerfile</span>
4541                    <span class="ws-lang-item">Elixir</span>
4542                    <span class="ws-lang-item">Erlang</span>
4543                    <span class="ws-lang-item">F#</span>
4544                    <span class="ws-lang-item">Go</span>
4545                    <span class="ws-lang-item">Groovy</span>
4546                    <span class="ws-lang-item">Haskell</span>
4547                    <span class="ws-lang-item">HTML</span>
4548                    <span class="ws-lang-item">Java</span>
4549                    <span class="ws-lang-item">JavaScript</span>
4550                    <span class="ws-lang-item">Julia</span>
4551                    <span class="ws-lang-item">Kotlin</span>
4552                    <span class="ws-lang-item">Lua</span>
4553                    <span class="ws-lang-item">Makefile</span>
4554                    <span class="ws-lang-item">Nim</span>
4555                    <span class="ws-lang-item">Obj-C</span>
4556                    <span class="ws-lang-item">OCaml</span>
4557                    <span class="ws-lang-item">Perl</span>
4558                    <span class="ws-lang-item">PHP</span>
4559                    <span class="ws-lang-item">PowerShell</span>
4560                    <span class="ws-lang-item">Python</span>
4561                    <span class="ws-lang-item">R</span>
4562                    <span class="ws-lang-item">Ruby</span>
4563                    <span class="ws-lang-item">Rust</span>
4564                    <span class="ws-lang-item">Scala</span>
4565                    <span class="ws-lang-item">SCSS</span>
4566                    <span class="ws-lang-item">Shell</span>
4567                    <span class="ws-lang-item">SQL</span>
4568                    <span class="ws-lang-item">Svelte</span>
4569                    <span class="ws-lang-item">Swift</span>
4570                    <span class="ws-lang-item">TypeScript</span>
4571                    <span class="ws-lang-item">Vue</span>
4572                    <span class="ws-lang-item">XML</span>
4573                    <span class="ws-lang-item">Zig</span>
4574                  </div>
4575                </div>
4576              </span>
4577            </span>
4578          </div>
4579          <div class="ws-divider"></div>
4580          <div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
4581          <div class="ws-divider"></div>
4582          <div class="ws-stat"><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
4583          <div class="ws-divider"></div>
4584          <div class="ws-stat ws-stat-output">
4585            <span class="ws-label">Output</span>
4586            <span class="ws-value">
4587              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
4588                <span id="ws-output-root">project/sloc</span>
4589              </button>
4590            </span>
4591          </div>
4592        </div>
4593      </div>
4594      <div class="workbench-box ws-history-group">
4595        <div class="ws-history-label">Scan history</div>
4596        <div class="ws-history-inner">
4597          <div class="ws-mini-box ws-mini-box-sm">
4598            <div class="ws-mini-label">Scans</div>
4599            <div class="ws-mini-value" id="ws-scan-count">—</div>
4600          </div>
4601          <div class="ws-mini-box ws-mini-box-lg">
4602            <div class="ws-mini-label">Last Scan</div>
4603            <div class="ws-mini-value" id="ws-last-scan">—</div>
4604          </div>
4605          <div class="ws-mini-box ws-mini-box-br">
4606            <div class="ws-mini-label">Branch</div>
4607            <div class="ws-mini-value" id="ws-branch">—</div>
4608          </div>
4609        </div>
4610      </div>
4611    </div>
4612
4613    <div class="layout">
4614      <aside class="side-stack">
4615        <section class="step-nav">
4616        <h3>Guided scan setup</h3>
4617        <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span></button>
4618        <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
4619        <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
4620        <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
4621
4622        <div class="step-nav-info" id="step-nav-info">
4623          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
4624          <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>
4625        </div>
4626
4627        <div class="quick-scan-divider"></div>
4628        <div class="quick-scan-section">
4629          <div class="quick-scan-label">No customization needed?</div>
4630          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
4631            <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>
4632            Quick Scan
4633          </button>
4634          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
4635        </div>
4636        </section>
4637
4638      </aside>
4639
4640      <section class="card">
4641        <div class="card-header">
4642          <div class="card-title-row">
4643            <div>
4644              <h1 class="card-title">Guided scan configuration</h1>
4645              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
4646            </div>
4647            <div class="wizard-progress" aria-label="Scan setup progress">
4648              <div class="wizard-progress-top">
4649                <span class="wizard-progress-label">Setup progress</span>
4650                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
4651              </div>
4652              <div class="wizard-progress-track">
4653                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
4654              </div>
4655            </div>
4656          </div>
4657        </div>
4658        <div class="card-body">
4659          <form method="post" action="/analyze" id="analyze-form">
4660            <div class="wizard-step active" data-step="1">
4661              <div class="section">
4662                <div class="section-kicker">Step 1</div>
4663                <h2>Select project and preview scope</h2>
4664                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
4665                <div class="field" style="margin:10px 0 0;">
4666                  <label for="path">Project path</label>
4667                  <div class="path-scope-grid">
4668                    <div class="input-group">
4669                      <input id="path" name="path" type="text" value="samples/basic" placeholder="/path/to/repository" required />
4670                      <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
4671                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
4672                    </div>
4673                    <div class="path-scope-sep"></div>
4674                    <div class="scope-legend-row">
4675                      <span class="scope-legend-label">Scope legend:</span>
4676                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
4677                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
4678                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
4679                    </div>
4680                  </div>
4681                  <div class="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</div>
4682                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
4683                </div>
4684
4685                <div style="height:1px;background:var(--line);margin:28px 0;"></div>
4686
4687                <div id="preview-panel" style="margin-top:0;">
4688                  <div class="preview-error">Loading preview...</div>
4689                </div>
4690              </div>
4691
4692              <div class="section">
4693                <div class="field-grid">
4694                  <div class="field">
4695                    <label for="include_globs">Include globs</label>
4696                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
4697                    <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>
4698                  </div>
4699                  <div class="field">
4700                    <label for="exclude_globs">Exclude globs</label>
4701                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
4702                    <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>
4703                  </div>
4704                </div>
4705                <div class="glob-guidance-grid">
4706                  <div class="glob-guidance-card">
4707                    <strong>How to read them</strong>
4708                    <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>
4709                  </div>
4710                  <div class="glob-guidance-card">
4711                    <strong>Common include examples</strong>
4712                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
4713                  </div>
4714                  <div class="glob-guidance-card">
4715                    <strong>Common exclude examples</strong>
4716                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
4717                  </div>
4718                </div>
4719              </div>
4720
4721              <div class="section" style="margin-top:14px;">
4722                <div class="preset-inline-row git-inline-row">
4723                  <div class="toggle-card" style="margin:0;">
4724                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
4725                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
4726                    <label class="checkbox">
4727                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
4728                      <div>
4729                        <span>Detect and separate git submodules</span>
4730                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
4731                      </div>
4732                    </label>
4733                  </div>
4734                  <div class="explainer-card prominent" style="margin:0;">
4735                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
4736                    <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>
4737                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
4738    path = libs/core
4739    url  = https://github.com/org/core.git
4740
4741[submodule "libs/ui"]
4742    path = libs/ui
4743    url  = https://github.com/org/ui.git</div>
4744                  </div>
4745                </div>
4746              </div>
4747
4748              <div class="wizard-actions">
4749                <div class="left"></div>
4750                <div class="right">
4751                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
4752                </div>
4753              </div>
4754            </div>
4755
4756            <div class="wizard-step" data-step="2">
4757              <div class="section">
4758                <div class="section-kicker">Step 2</div>
4759                <h2>Choose counting behavior</h2>
4760                <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. Counting methodology follows IEEE Std 1045-1992 physical SLOC.</p>
4761                <div class="subsection-bar">Primary line classification</div>
4762                <div class="preset-inline-row" style="align-items:start;">
4763                  <div class="toggle-card mixed-line-card" style="margin:0;">
4764                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
4765                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
4766                    <select id="mixed_line_policy" name="mixed_line_policy">
4767                      <option value="code_only">Code only</option>
4768                      <option value="code_and_comment">Code and comment</option>
4769                      <option value="comment_only">Comment only</option>
4770                      <option value="separate_mixed_category">Separate mixed category</option>
4771                    </select>
4772                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
4773                  </div>
4774                  <div class="explainer-card prominent" style="margin:0;">
4775                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
4776                    <div class="explainer-body" id="mixed-policy-description"></div>
4777                    <div class="code-sample" id="mixed-policy-example"></div>
4778                  </div>
4779                </div>
4780              </div>
4781
4782              <div class="subsection-bar">Additional scan rules</div>
4783              <div class="scan-rules-grid">
4784                <div class="preset-inline-row">
4785                  <div class="toggle-card" style="margin:0;">
4786                    <div class="field-help-title">Generated files</div>
4787                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
4788                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4789                  </div>
4790                  <div class="explainer-card prominent" style="margin:0;">
4791                    <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>
4792                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
4793# Files matching codegen patterns are excluded:
4794#   *.generated.cs  *.pb.go  *.g.dart</div>
4795                  </div>
4796                </div>
4797                <div class="preset-inline-row">
4798                  <div class="toggle-card" style="margin:0;">
4799                    <div class="field-help-title">Minified files</div>
4800                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
4801                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4802                  </div>
4803                  <div class="explainer-card prominent" style="margin:0;">
4804                    <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>
4805                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
4806# Heuristic: very long lines + low whitespace ratio
4807#   jquery.min.js  bundle.min.css  → skipped</div>
4808                  </div>
4809                </div>
4810                <div class="preset-inline-row">
4811                  <div class="toggle-card" style="margin:0;">
4812                    <div class="field-help-title">Vendor directories</div>
4813                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
4814                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4815                  </div>
4816                  <div class="explainer-card prominent" style="margin:0;">
4817                    <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>
4818                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
4819# Directories named vendor/ node_modules/ third_party/
4820#   → entire subtree is excluded from totals</div>
4821                  </div>
4822                </div>
4823                <div class="preset-inline-row">
4824                  <div class="toggle-card" style="margin:0;">
4825                    <div class="field-help-title">Lockfiles and manifests</div>
4826                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
4827                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
4828                  </div>
4829                  <div class="explainer-card prominent" style="margin:0;">
4830                    <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>
4831                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
4832# Files like package-lock.json  Cargo.lock  yarn.lock
4833#   → skipped unless this is enabled</div>
4834                  </div>
4835                </div>
4836                <div class="preset-inline-row">
4837                  <div class="toggle-card" style="margin:0;">
4838                    <div class="field-help-title">Binary handling</div>
4839                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
4840                    <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>
4841                  </div>
4842                  <div class="explainer-card prominent" style="margin:0;">
4843                    <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>
4844                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
4845# Detected via long lines + low whitespace heuristic
4846#   .png  .exe  .so  → skipped silently</div>
4847                  </div>
4848                </div>
4849                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
4850                  <div class="toggle-card" style="margin:0;">
4851                    <div class="field-help-title">Python docstrings</div>
4852                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
4853                    <label class="checkbox">
4854                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
4855                      <span>Count as comment-style lines</span>
4856                    </label>
4857                  </div>
4858                  <div class="explainer-card prominent" style="margin:0;">
4859                    <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>
4860                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
4861                  </div>
4862                </div>
4863              </div>
4864              <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
4865                  <div class="always-tracked-tip">
4866                    <div class="always-tracked-tip-icon">ℹ</div>
4867                    <div class="always-tracked-tip-body">
4868                      <div class="field-help-title">Always tracked — not configurable</div>
4869                      <h4>Comment and blank-line basics</h4>
4870                      <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>
4871                    </div>
4872                  </div>
4873                  <div class="always-tracked-tip">
4874                    <div class="always-tracked-tip-icon">→</div>
4875                    <div class="always-tracked-tip-body">
4876                      <div class="field-help-title">What these settings change</div>
4877                      <h4>Lines on the boundary</h4>
4878                      <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>
4879                    </div>
4880                  </div>
4881                </div>
4882
4883              <div class="wizard-actions">
4884                <div class="left">
4885                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
4886                </div>
4887                <div class="right">
4888                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
4889                </div>
4890              </div>
4891            </div>
4892
4893            <div class="wizard-step" data-step="3">
4894              <div class="section">
4895                <div class="section-kicker">Step 3</div>
4896                <h2>Output and report identity</h2>
4897                <p class="card-subtitle step3-subtitle">Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
4898                <div class="preset-inline-row" style="align-items:start;">
4899                  <div class="toggle-card" style="margin:0;">
4900                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
4901                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
4902                    <select id="scan_preset">
4903                      <option value="balanced">Balanced local scan</option>
4904                      <option value="code_focused">Code focused</option>
4905                      <option value="comment_audit">Comment audit</option>
4906                      <option value="deep_review">Deep review</option>
4907                    </select>
4908                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
4909                  </div>
4910                  <div class="explainer-card">
4911                    <div class="field-help-title">Selected scan preset</div>
4912                    <div class="explainer-body" id="scan-preset-description"></div>
4913                    <div class="preset-summary-row" id="scan-preset-summary"></div>
4914                    <div class="code-sample" id="scan-preset-example"></div>
4915                    <div class="preset-note" id="scan-preset-note"></div>
4916                  </div>
4917                </div>
4918                <hr class="step3-separator" />
4919                <div class="preset-inline-row" style="align-items:start;">
4920                  <div class="toggle-card" style="margin:0;">
4921                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
4922                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
4923                    <select id="artifact_preset">
4924                      <option value="review">Review bundle</option>
4925                      <option value="full">Full bundle</option>
4926                      <option value="html_only">HTML only</option>
4927                      <option value="machine">Machine bundle</option>
4928                    </select>
4929                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
4930                  </div>
4931                  <div class="explainer-card">
4932                    <div class="field-help-title">Selected artifact preset</div>
4933                    <div class="explainer-body" id="artifact-preset-description"></div>
4934                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
4935                    <div class="code-sample" id="artifact-preset-example"></div>
4936                  </div>
4937                </div>
4938              </div>
4939
4940              <div class="section section-spacer-top">
4941                <div class="output-field-row">
4942                  <div class="field">
4943                    <label for="output_dir">Output directory</label>
4944                    <div class="input-group compact">
4945                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
4946                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
4947                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
4948                    </div>
4949                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
4950                  </div>
4951                  <div class="output-field-aside">
4952                    <strong>Where reports land</strong>
4953                    Each run creates a timestamped subfolder here containing the selected artifacts. This path is separate from the project being scanned and does not affect what files are analyzed.
4954                  </div>
4955                </div>
4956              </div>
4957
4958              <div class="section section-spacer-top">
4959                <div class="output-field-row">
4960                  <div class="field">
4961                    <label for="report_title">Report title</label>
4962                    <input id="report_title" name="report_title" type="text" value="samples/basic" placeholder="Project report title" />
4963                    <div class="hint">Appears in HTML and PDF output headers.</div>
4964                  </div>
4965                  <div class="output-field-aside">
4966                    <strong>Shown in exported artifacts</strong>
4967                    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.
4968                  </div>
4969                </div>
4970              </div>
4971
4972              <div class="section">
4973                <div class="section-kicker">Artifacts</div>
4974                <div class="artifact-grid">
4975                  <div class="artifact-card selected" data-artifact="html">
4976                    <div class="marker">✓</div>
4977                    <div class="artifact-icon">H</div>
4978                    <h4>HTML report</h4>
4979                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
4980                    <div class="artifact-tags">
4981                      <span class="soft-chip">Best for visual review</span>
4982                      <span class="soft-chip">Embeddable preview</span>
4983                    </div>
4984                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
4985                  </div>
4986                  <div class="artifact-card selected" data-artifact="pdf">
4987                    <div class="marker">✓</div>
4988                    <div class="artifact-icon">P</div>
4989                    <h4>PDF export</h4>
4990                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
4991                    <div class="artifact-tags">
4992                      <span class="soft-chip">Portable snapshot</span>
4993                      <span class="soft-chip">Good for handoff</span>
4994                    </div>
4995                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
4996                  </div>
4997                  <div class="artifact-card selected" data-artifact="json" style="opacity:0.75;pointer-events:none;">
4998                    <div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
4999                    <div class="artifact-icon">J</div>
5000                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">Always on</span></h4>
5001                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
5002                    <div class="artifact-tags">
5003                      <span class="soft-chip">Required for compare</span>
5004                      <span class="soft-chip">Auto-enabled</span>
5005                    </div>
5006                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
5007                  </div>
5008                </div>
5009                <div class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</div>
5010              </div>
5011
5012              <div class="wizard-actions">
5013                <div class="left">
5014                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
5015                </div>
5016                <div class="right">
5017                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
5018                </div>
5019              </div>
5020            </div>
5021
5022            <div class="wizard-step" data-step="4">
5023              <div class="section">
5024                <div class="section-kicker">Step 4</div>
5025                <h2>Review selections and run</h2>
5026                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
5027                <div class="review-grid">
5028                  <div class="review-card highlight">
5029                    <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>
5030                    <ul id="review-scan-summary"></ul>
5031                  </div>
5032                  <div class="review-card highlight">
5033                    <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>
5034                    <ul id="review-count-summary"></ul>
5035                  </div>
5036                  <div class="review-card">
5037                    <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>
5038                    <ul id="review-artifact-summary"></ul>
5039                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
5040                  </div>
5041                  <div class="review-card">
5042                    <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>
5043                    <ul id="review-preview-summary"></ul>
5044                  </div>
5045                </div>
5046              </div>
5047
5048              <div class="wizard-actions">
5049                <div class="left">
5050                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
5051                </div>
5052                <div class="right">
5053                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
5054                </div>
5055              </div>
5056            </div></form>
5057        </div>
5058      </section>
5059    </div>
5060  </div>
5061
5062  <script nonce="{{ csp_nonce }}">
5063    (function () {
5064      var form = document.getElementById("analyze-form");
5065      var loading = document.getElementById("loading");
5066      var submitButton = document.getElementById("submit-button");
5067      var pathInput = document.getElementById("path");
5068      var outputDirInput = document.getElementById("output_dir");
5069      var reportTitleInput = document.getElementById("report_title");
5070      var previewPanel = document.getElementById("preview-panel");
5071      var refreshButton = document.getElementById("refresh-preview");
5072      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
5073      var useSamplePath = document.getElementById("use-sample-path");
5074      var useDefaultOutput = document.getElementById("use-default-output");
5075      var browsePath = document.getElementById("browse-path");
5076      var browseOutputDir = document.getElementById("browse-output-dir");
5077      var themeToggle = document.getElementById("theme-toggle");
5078      var mixedLinePolicy = document.getElementById("mixed_line_policy");
5079      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
5080      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
5081      var scanPreset = document.getElementById("scan_preset");
5082      var artifactPreset = document.getElementById("artifact_preset");
5083      var includeGlobsInput = document.getElementById("include_globs");
5084      var excludeGlobsInput = document.getElementById("exclude_globs");
5085      var liveReportTitle = document.getElementById("live-report-title");
5086      var navProjectPill = document.getElementById("nav-project-pill");
5087      var navProjectTitle = document.getElementById("nav-project-title");
5088      var reportTitlePreview = null;
5089      var wizardProgressFill = document.getElementById("wizard-progress-fill");
5090      var wizardProgressValue = document.getElementById("wizard-progress-value");
5091      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
5092      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
5093      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
5094      var reportTitleTouched = false;
5095      var currentStep = 1;
5096      var previewTimer = null;
5097      var quickScanBtn = document.getElementById("quick-scan-btn");
5098
5099      if (quickScanBtn) {
5100        quickScanBtn.addEventListener("click", function () {
5101          var pathVal = pathInput ? pathInput.value.trim() : "";
5102          if (!pathVal) {
5103            alert("Please enter or browse to a project path first.");
5104            return;
5105          }
5106          quickScanBtn.disabled = true;
5107          quickScanBtn.textContent = "Scanning...";
5108          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
5109          if (loading) loading.classList.add("active");
5110          if (form) form.submit();
5111        });
5112      }
5113
5114      var mixedPolicyInfo = {
5115        code_only: {
5116          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.",
5117          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'
5118        },
5119        code_and_comment: {
5120          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.",
5121          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'
5122        },
5123        comment_only: {
5124          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.",
5125          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'
5126        },
5127        separate_mixed_category: {
5128          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.",
5129          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'
5130        }
5131      };
5132
5133      var scanPresetInfo = {
5134        balanced: {
5135          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.",
5136          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
5137          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
5138          note: "Best when you want a stable local overview before making deeper adjustments.",
5139          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
5140        },
5141        code_focused: {
5142          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
5143          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
5144          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
5145          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
5146          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
5147        },
5148        comment_audit: {
5149          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
5150          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
5151          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
5152          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
5153          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
5154        },
5155        deep_review: {
5156          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
5157          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
5158          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
5159          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
5160          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
5161        }
5162      };
5163
5164      var artifactPresetInfo = {
5165        review: {
5166          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.",
5167          chips: ["HTML", "PDF"],
5168          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
5169        },
5170        full: {
5171          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.",
5172          chips: ["HTML", "PDF", "JSON"],
5173          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
5174        },
5175        html_only: {
5176          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.",
5177          chips: ["HTML only", "Fast local review"],
5178          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
5179        },
5180        machine: {
5181          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
5182          chips: ["HTML", "JSON"],
5183          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
5184        }
5185      };
5186
5187      function applyTheme(theme) {
5188        if (theme === "dark") document.body.classList.add("dark-theme");
5189        else document.body.classList.remove("dark-theme");
5190      }
5191
5192      function loadSavedTheme() {
5193        var saved = null;
5194        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
5195        applyTheme(saved === "dark" ? "dark" : "light");
5196      }
5197
5198      function updateScrollProgress() {
5199        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
5200        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
5201        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
5202        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
5203        var step = Math.min(Math.max(currentStep, 1), 4);
5204        var base = stepBase[step];
5205        var end  = stepEnd[step];
5206
5207        var scrollFrac = 0;
5208        var activePanel = document.querySelector(".wizard-step.active");
5209        if (activePanel) {
5210          var scrollTop = window.scrollY || window.pageYOffset || 0;
5211          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
5212          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
5213          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
5214          var scrolled = scrollTop + viewH - panelTop;
5215          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
5216        }
5217
5218        var percent = Math.round(base + (end - base) * scrollFrac);
5219        percent = Math.min(end, Math.max(base, percent));
5220        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
5221        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
5222      }
5223
5224      function updateWizardProgress() {
5225        updateScrollProgress();
5226      }
5227
5228      var stepDescriptions = [
5229        "Choose a project folder, apply scope filters, and preview which files will be counted.",
5230        "Configure how mixed code-plus-comment lines and docstrings are classified.",
5231        "Pick your output formats, scan preset, and where reports are saved.",
5232        "Review all settings and launch the analysis."
5233      ];
5234
5235      function updateStepNav(step) {
5236        var infoLabel = document.getElementById("step-nav-info-label");
5237        var infoDesc  = document.getElementById("step-nav-info-desc");
5238        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
5239        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
5240
5241      }
5242
5243      function setStep(step, pushHistory) {
5244        currentStep = step;
5245        stepPanels.forEach(function (panel) {
5246          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
5247        });
5248        stepButtons.forEach(function (button) {
5249          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
5250        });
5251        var layoutEl = document.querySelector(".layout");
5252        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
5253        updateWizardProgress();
5254        updateStepNav(step);
5255
5256        if (pushHistory !== false) {
5257          try {
5258            history.pushState({ wizardStep: step }, "", "#step" + step);
5259          } catch (e) {}
5260        }
5261
5262        window.scrollTo({ top: 0, behavior: "instant" });
5263      }
5264
5265      window.addEventListener("popstate", function (e) {
5266        if (e.state && e.state.wizardStep) {
5267          setStep(e.state.wizardStep, false);
5268        } else {
5269          var hashMatch = location.hash.match(/^#step([1-4])$/);
5270          if (hashMatch) setStep(Number(hashMatch[1]), false);
5271        }
5272      });
5273
5274      function inferTitleFromPath(value) {
5275        if (!value) return "project";
5276        var cleaned = value.replace(/[\/\\]+$/, "");
5277        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
5278        return parts.length ? parts[parts.length - 1] : value;
5279      }
5280
5281      function updateReportTitleFromPath() {
5282        var inferred = inferTitleFromPath(pathInput.value || "samples/basic");
5283        if (!reportTitleTouched) {
5284          reportTitleInput.value = inferred;
5285        }
5286        var title = reportTitleInput.value || inferred;
5287        if (liveReportTitle) liveReportTitle.textContent = title;
5288        if (reportTitlePreview) reportTitlePreview.textContent = title;
5289        document.title = "OxideSLOC | " + title;
5290
5291        var projectPath = (pathInput.value || "").trim();
5292        if (navProjectPill && navProjectTitle) {
5293          if (projectPath.length > 0) {
5294            navProjectTitle.textContent = inferred;
5295            navProjectPill.classList.add("visible");
5296          } else {
5297            navProjectTitle.textContent = "";
5298            navProjectPill.classList.remove("visible");
5299          }
5300        }
5301      }
5302
5303      function updateMixedPolicyUI() {
5304        var key = mixedLinePolicy.value || "code_only";
5305        var info = mixedPolicyInfo[key];
5306        document.getElementById("mixed-policy-description").textContent = info.description;
5307        document.getElementById("mixed-policy-example").textContent = info.example;
5308      }
5309
5310      function updatePythonDocstringUI() {
5311        var checked = !!pythonDocstrings.checked;
5312        document.getElementById("python-docstring-example").textContent = checked
5313          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
5314          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
5315        document.getElementById("python-docstring-live-help").textContent = checked
5316          ? "Enabled: docstrings contribute to comment-style totals."
5317          : "Disabled: docstrings are not counted as comment content.";
5318      }
5319
5320      function renderPresetChips(targetId, chips) {
5321        var target = document.getElementById(targetId);
5322        if (!target) return;
5323        target.innerHTML = (chips || []).map(function (chip) {
5324          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
5325        }).join('');
5326      }
5327
5328      function updatePresetDescriptions() {
5329        var scanInfo = scanPresetInfo[scanPreset.value];
5330        var artifactInfo = artifactPresetInfo[artifactPreset.value];
5331        document.getElementById("scan-preset-description").textContent = scanInfo.description;
5332        document.getElementById("scan-preset-example").textContent = scanInfo.example;
5333        document.getElementById("scan-preset-note").textContent = scanInfo.note;
5334        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
5335        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
5336        renderPresetChips("scan-preset-summary", scanInfo.chips);
5337        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
5338      }
5339
5340      function applyScanPreset() {
5341        var info = scanPresetInfo[scanPreset.value];
5342        if (!info || !info.apply) return;
5343        mixedLinePolicy.value = info.apply.mixed;
5344        pythonDocstrings.checked = !!info.apply.docstrings;
5345        document.getElementById("generated_file_detection").value = info.apply.generated;
5346        document.getElementById("minified_file_detection").value = info.apply.minified;
5347        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
5348        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
5349        document.getElementById("binary_file_behavior").value = info.apply.binary;
5350        updateMixedPolicyUI();
5351        updatePythonDocstringUI();
5352      }
5353
5354      function applyArtifactPreset() {
5355        var enabled = { html: false, pdf: false, json: false };
5356        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
5357        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
5358        if (artifactPreset.value === "html_only") { enabled.html = true; }
5359        if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
5360
5361        artifactCards.forEach(function (card) {
5362          var artifact = card.getAttribute("data-artifact");
5363          var checked = !!enabled[artifact];
5364          var checkbox = card.querySelector(".artifact-checkbox");
5365          checkbox.checked = checked;
5366          card.classList.toggle("selected", checked);
5367        });
5368      }
5369
5370      function toggleArtifactCard(card) {
5371        var checkbox = card.querySelector(".artifact-checkbox");
5372        checkbox.checked = !checkbox.checked;
5373        card.classList.toggle("selected", checkbox.checked);
5374      }
5375
5376      function updateReview() {
5377        var scanSummary = document.getElementById("review-scan-summary");
5378        var countSummary = document.getElementById("review-count-summary");
5379        var artifactSummary = document.getElementById("review-artifact-summary");
5380        var outputSummary = document.getElementById("review-output-summary");
5381        var previewSummary = document.getElementById("review-preview-summary");
5382        var readinessSummary = document.getElementById("review-readiness-summary");
5383        var includeText = document.getElementById("include_globs").value.trim();
5384        var excludeText = document.getElementById("exclude_globs").value.trim();
5385        var sidePathPreview = document.getElementById("side-path-preview");
5386        var sideOutputPreview = document.getElementById("side-output-preview");
5387        var sideTitlePreview = document.getElementById("side-title-preview");
5388
5389        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "samples/basic"; }
5390        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
5391        if (sideTitlePreview) {
5392          var rt = document.getElementById("report_title");
5393          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
5394        }
5395
5396        scanSummary.innerHTML = ""
5397          + "<li>Path: " + escapeHtml(pathInput.value || "samples/basic") + "</li>"
5398          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
5399          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
5400
5401        countSummary.innerHTML = ""
5402          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
5403          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
5404          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
5405          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
5406          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
5407          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
5408          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
5409          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
5410
5411        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.querySelector("h4").textContent; });
5412        artifactSummary.innerHTML = ""
5413          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
5414          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
5415
5416        outputSummary.innerHTML = ""
5417          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
5418          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value || "samples/basic")) + "</li>";
5419
5420        if (previewSummary) {
5421          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
5422          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
5423          var statMap = {};
5424          statButtons.forEach(function (button) {
5425            var valueNode = button.querySelector('.scope-stat-value');
5426            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
5427          });
5428          previewSummary.innerHTML = ''
5429            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
5430            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
5431            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
5432            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
5433            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
5434            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
5435
5436          if (readinessSummary) {
5437            var selectedArtifactsCount = selectedArtifacts.length;
5438            readinessSummary.innerHTML = ''
5439              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
5440              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
5441              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
5442              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
5443          }
5444        }
5445      }
5446
5447      function escapeHtml(value) {
5448        return String(value)
5449          .replace(/&/g, "&amp;")
5450          .replace(/</g, "&lt;")
5451          .replace(/>/g, "&gt;")
5452          .replace(/"/g, "&quot;")
5453          .replace(/'/g, "&#39;");
5454      }
5455
5456      function isPythonVisible() {
5457        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
5458      }
5459
5460      function syncPythonVisibility() {
5461        var html = previewPanel.textContent || "";
5462        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
5463        pythonWraps.forEach(function (node) {
5464          node.classList.toggle("hidden", !hasPython);
5465        });
5466      }
5467
5468      function attachPreviewInteractions() {
5469        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
5470        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
5471        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
5472        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
5473        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
5474        var searchInput = previewPanel.querySelector("#explorer-search");
5475        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
5476        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
5477        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
5478        var activeFilter = "all";
5479        var activeLanguage = "";
5480        var searchTerm = "";
5481        var currentSortKey = null;
5482        var currentSortOrder = "asc";
5483        var childRows = {};
5484
5485        rows.forEach(function (row) {
5486          var parentId = row.getAttribute("data-parent-id") || "";
5487          var rowId = row.getAttribute("data-row-id") || "";
5488          if (!childRows[parentId]) childRows[parentId] = [];
5489          childRows[parentId].push(rowId);
5490        });
5491
5492        function rowById(id) {
5493          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
5494        }
5495
5496        function hasCollapsedAncestor(row) {
5497          var parentId = row.getAttribute("data-parent-id");
5498          while (parentId) {
5499            var parent = rowById(parentId);
5500            if (!parent) break;
5501            if (parent.getAttribute("data-expanded") === "false") return true;
5502            parentId = parent.getAttribute("data-parent-id");
5503          }
5504          return false;
5505        }
5506
5507        function updateToggleGlyph(row) {
5508          var toggle = row.querySelector(".tree-toggle");
5509          if (!toggle) return;
5510          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
5511        }
5512
5513        function rowSortValue(row, key) {
5514          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
5515        }
5516
5517        function updateSortButtons() {
5518          sortButtons.forEach(function (button) {
5519            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
5520            var indicator = button.querySelector(".tree-sort-indicator");
5521            button.classList.toggle("active", isActive);
5522            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
5523            if (indicator) {
5524              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
5525            }
5526          });
5527        }
5528
5529        function sortSiblingRows() {
5530          if (!treeContainer) {
5531            updateSortButtons();
5532            return;
5533          }
5534
5535          var rowMap = {};
5536          var childrenMap = {};
5537          rows.forEach(function (row) {
5538            var rowId = row.getAttribute("data-row-id");
5539            var parentId = row.getAttribute("data-parent-id") || "";
5540            rowMap[rowId] = row;
5541            if (!childrenMap[parentId]) childrenMap[parentId] = [];
5542            childrenMap[parentId].push(rowId);
5543          });
5544
5545          Object.keys(childrenMap).forEach(function (parentId) {
5546            if (!parentId) return;
5547            childrenMap[parentId].sort(function (a, b) {
5548              var rowA = rowMap[a];
5549              var rowB = rowMap[b];
5550              if (!currentSortKey) {
5551                return Number(a) - Number(b);
5552              }
5553              var valueA = rowSortValue(rowA, currentSortKey);
5554              var valueB = rowSortValue(rowB, currentSortKey);
5555              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
5556              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
5557              var fallbackA = rowSortValue(rowA, "name");
5558              var fallbackB = rowSortValue(rowB, "name");
5559              if (fallbackA < fallbackB) return -1;
5560              if (fallbackA > fallbackB) return 1;
5561              return Number(a) - Number(b);
5562            });
5563          });
5564
5565          var orderedIds = [];
5566          function pushChildren(parentId) {
5567            (childrenMap[parentId] || []).forEach(function (childId) {
5568              orderedIds.push(childId);
5569              pushChildren(childId);
5570            });
5571          }
5572
5573          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
5574            orderedIds.push(topId);
5575            pushChildren(topId);
5576          });
5577
5578          orderedIds.forEach(function (id) {
5579            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
5580          });
5581          updateSortButtons();
5582        }
5583
5584        function updateLanguageButtons() {
5585          languageButtons.forEach(function (button) {
5586            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
5587            var isActive = languageValue === activeLanguage;
5588            button.classList.toggle("active", isActive);
5589          });
5590        }
5591
5592        function rowSelfMatches(row) {
5593          var kind = row.getAttribute("data-kind");
5594          var status = row.getAttribute("data-status");
5595          var language = (row.getAttribute("data-language") || "").toLowerCase();
5596          var name = row.getAttribute("data-name-lower") || "";
5597          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
5598          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
5599          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
5600          var passesLanguage = !activeLanguage || language === activeLanguage;
5601          return passesFilter && passesSearch && passesLanguage;
5602        }
5603
5604        function hasMatchingDescendant(rowId) {
5605          return (childRows[rowId] || []).some(function (childId) {
5606            var childRow = rowById(childId);
5607            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
5608          });
5609        }
5610
5611        function rowMatches(row) {
5612          if (rowSelfMatches(row)) return true;
5613          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
5614        }
5615
5616        function resetViewState() {
5617          activeFilter = "all";
5618          activeLanguage = "";
5619          searchTerm = "";
5620          currentSortKey = null;
5621          currentSortOrder = "asc";
5622          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5623          if (searchInput) searchInput.value = "";
5624          if (filterSelect) filterSelect.value = "all";
5625          updateLanguageButtons();
5626        }
5627
5628        function applyVisibility() {
5629          rows.forEach(function (row) {
5630            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
5631            row.classList.toggle("hidden-by-filter", !visible);
5632            row.style.display = visible ? "grid" : "none";
5633          });
5634          buttons.forEach(function (button) {
5635            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
5636          });
5637          if (filterSelect) filterSelect.value = activeFilter;
5638        }
5639
5640        buttons.forEach(function (button) {
5641          button.addEventListener("click", function () {
5642            var filterValue = button.getAttribute("data-filter") || "all";
5643            if (filterValue === "reset-view") {
5644              resetViewState();
5645              sortSiblingRows();
5646              applyVisibility();
5647              return;
5648            }
5649            activeFilter = filterValue;
5650            applyVisibility();
5651          });
5652        });
5653
5654        rows.forEach(function (row) {
5655          updateToggleGlyph(row);
5656          var toggle = row.querySelector(".tree-toggle");
5657          if (toggle) {
5658            toggle.addEventListener("click", function () {
5659              var expanded = row.getAttribute("data-expanded") !== "false";
5660              row.setAttribute("data-expanded", expanded ? "false" : "true");
5661              updateToggleGlyph(row);
5662              applyVisibility();
5663            });
5664          }
5665        });
5666
5667        actionButtons.forEach(function (button) {
5668          button.addEventListener("click", function () {
5669            var action = button.getAttribute("data-explorer-action");
5670            if (action === "expand-all") {
5671              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5672            } else if (action === "collapse-all") {
5673              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
5674            } else if (action === "clear-filters") {
5675              resetViewState();
5676            }
5677            sortSiblingRows();
5678            applyVisibility();
5679          });
5680        });
5681
5682        if (filterSelect) {
5683          filterSelect.addEventListener("change", function () {
5684            activeFilter = filterSelect.value || "all";
5685            applyVisibility();
5686          });
5687        }
5688
5689        languageButtons.forEach(function (button) {
5690          button.addEventListener("click", function () {
5691            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
5692            updateLanguageButtons();
5693            applyVisibility();
5694          });
5695        });
5696
5697        sortButtons.forEach(function (button) {
5698          button.addEventListener("click", function () {
5699            var sortKey = button.getAttribute("data-sort-key");
5700            if (currentSortKey === sortKey) {
5701              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
5702            } else {
5703              currentSortKey = sortKey;
5704              currentSortOrder = "asc";
5705            }
5706            sortSiblingRows();
5707            applyVisibility();
5708          });
5709        });
5710
5711        if (searchInput) {
5712          searchInput.addEventListener("input", function () {
5713            searchTerm = searchInput.value.trim().toLowerCase();
5714            applyVisibility();
5715          });
5716        }
5717
5718        updateLanguageButtons();
5719        sortSiblingRows();
5720        applyVisibility();
5721      }
5722
5723      function loadPreview() {
5724        if (!previewPanel || !pathInput) return;
5725        var path = pathInput.value || "samples/basic";
5726        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
5727        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
5728        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
5729        var previewUrl = "/preview?path=" + encodeURIComponent(path)
5730          + "&include_globs=" + encodeURIComponent(includeValue)
5731          + "&exclude_globs=" + encodeURIComponent(excludeValue);
5732        fetch(previewUrl)
5733          .then(function (response) { return response.text(); })
5734          .then(function (html) {
5735            previewPanel.innerHTML = html;
5736            attachPreviewInteractions();
5737            syncPythonVisibility();
5738            updateReview();
5739            setTimeout(collapseLanguagePills, 50);
5740          })
5741          .catch(function (err) {
5742            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
5743          });
5744      }
5745
5746      function pickDirectory(targetInput, kind) {
5747        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
5748        if (browseButton) browseButton.disabled = true;
5749
5750        if (previewPanel && targetInput === pathInput) {
5751          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
5752        }
5753
5754        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
5755          .then(function (response) { return response.json(); })
5756          .then(function (data) {
5757            if (data && data.selected_path) {
5758              targetInput.value = data.selected_path;
5759
5760              if (targetInput === pathInput) {
5761                updateReportTitleFromPath();
5762                autoSetOutputDir(data.selected_path);
5763                fetchProjectHistory(data.selected_path);
5764                loadPreview();
5765              }
5766
5767              updateReview();
5768            } else if (targetInput === pathInput) {
5769              // Cancelled — keep existing value and refresh preview with current path
5770              loadPreview();
5771            }
5772          })
5773          .catch(function () {
5774            window.alert("Directory picker request failed.");
5775            if (previewPanel && targetInput === pathInput) {
5776              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
5777            }
5778          })
5779          .finally(function () {
5780            if (browseButton) browseButton.disabled = false;
5781          });
5782      }
5783
5784      if (themeToggle) {
5785        themeToggle.addEventListener("click", function () {
5786          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
5787          applyTheme(nextTheme);
5788          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
5789        });
5790      }
5791
5792      stepButtons.forEach(function (button) {
5793        button.addEventListener("click", function () {
5794          setStep(Number(button.getAttribute("data-step-target")));
5795        });
5796      });
5797
5798      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
5799        button.addEventListener("click", function () {
5800          setStep(Number(button.getAttribute("data-step-target")) || 1);
5801        });
5802      });
5803
5804      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
5805        button.addEventListener("click", function () {
5806          updateReview();
5807          setStep(Number(button.getAttribute("data-next")));
5808        });
5809      });
5810
5811      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
5812        button.addEventListener("click", function () {
5813          setStep(Number(button.getAttribute("data-prev")));
5814        });
5815      });
5816
5817      if (useSamplePath) {
5818        useSamplePath.addEventListener("click", function () {
5819          pathInput.value = "samples/basic";
5820          updateReportTitleFromPath();
5821          loadPreview();
5822        });
5823      }
5824
5825      if (useDefaultOutput) {
5826        useDefaultOutput.addEventListener("click", function () {
5827          delete outputDirInput.dataset.userEdited;
5828          autoSetOutputDir(pathInput ? pathInput.value : "");
5829          updateReview();
5830        });
5831      }
5832
5833      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
5834      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
5835
5836      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
5837
5838      // ── Language pill overflow: collapse to "+N more" chip ─────────────
5839      function collapseLanguagePills() {
5840        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
5841        rows.forEach(function(row) {
5842          // Remove any previous overflow chip
5843          var prev = row.querySelector('.lang-overflow-chip');
5844          if (prev) prev.remove();
5845          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
5846          pills.forEach(function(p) { p.style.display = ''; });
5847          if (!pills.length) return;
5848
5849          // Measure after restoring all pills
5850          var containerRight = row.getBoundingClientRect().right;
5851          var hidden = [];
5852          for (var i = pills.length - 1; i >= 1; i--) {
5853            var rect = pills[i].getBoundingClientRect();
5854            if (rect.right > containerRight + 2) {
5855              hidden.unshift(pills[i]);
5856              pills[i].style.display = 'none';
5857            } else {
5858              break;
5859            }
5860          }
5861
5862          if (hidden.length) {
5863            var chip = document.createElement('button');
5864            chip.type = 'button';
5865            chip.className = 'language-pill lang-overflow-chip';
5866            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
5867            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
5868            row.appendChild(chip);
5869          }
5870        });
5871      }
5872
5873      // Run after preview loads (preview panel populates language pills)
5874      var _origLoadPreviewCb = window.__previewLoaded;
5875      document.addEventListener('previewLoaded', collapseLanguagePills);
5876      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
5877      setTimeout(collapseLanguagePills, 400);
5878
5879      // ── Project history & output dir auto-set ──────────────────────────
5880      var wsOutputRoot   = document.getElementById("ws-output-root");
5881      var wsScanCount    = document.getElementById("ws-scan-count");
5882      var wsLastScan     = document.getElementById("ws-last-scan");
5883      var historyBadge   = document.getElementById("path-history-badge");
5884      var historyTimer   = null;
5885
5886      var wsOutputLink = document.getElementById("ws-output-link");
5887      function syncStripOutputRoot() {
5888        var val = outputDirInput ? outputDirInput.value : "";
5889        var display = val || "project/sloc";
5890        if (wsOutputRoot) wsOutputRoot.textContent = display;
5891        if (wsOutputLink) wsOutputLink.dataset.folder = val;
5892      }
5893
5894      function autoSetOutputDir(projectPath) {
5895        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
5896        if (!projectPath || !projectPath.trim()) return;
5897        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
5898        outputDirInput.value = cleaned + "/sloc";
5899        syncStripOutputRoot();
5900        updateReview();
5901      }
5902
5903      var wsBranch = document.getElementById("ws-branch");
5904
5905      function fetchProjectHistory(projectPath) {
5906        if (!projectPath || !projectPath.trim()) {
5907          if (wsScanCount) wsScanCount.textContent = "—";
5908          if (wsLastScan)  wsLastScan.textContent  = "—";
5909          if (wsBranch)    wsBranch.textContent    = "—";
5910          if (historyBadge) historyBadge.style.display = "none";
5911          return;
5912        }
5913        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
5914          .then(function (r) { return r.ok ? r.json() : null; })
5915          .then(function (data) {
5916            if (!data) return;
5917            var countStr = data.scan_count > 0
5918              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
5919              : "never";
5920            var tsStr = data.last_scan_timestamp
5921              ? data.last_scan_timestamp.replace(" UTC","")
5922              : "—";
5923            if (wsScanCount) wsScanCount.textContent = countStr;
5924            if (wsLastScan)  wsLastScan.textContent  = tsStr;
5925            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
5926            if (data.scan_count > 0) {
5927              if (historyBadge) {
5928                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
5929                historyBadge.textContent = data.scan_count + " previous scan" +
5930                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
5931                  "Last: " + (data.last_scan_timestamp || "—") +
5932                  " — " + (data.last_scan_code_lines ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " code lines.";
5933                historyBadge.className = "path-history-badge found";
5934                historyBadge.style.display = "";
5935              }
5936            } else {
5937              if (historyBadge) historyBadge.style.display = "none";
5938            }
5939          })
5940          .catch(function () {});
5941      }
5942
5943      function onPathChange() {
5944        var val = pathInput ? pathInput.value : "";
5945        updateReportTitleFromPath();
5946        autoSetOutputDir(val);
5947        clearTimeout(historyTimer);
5948        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
5949        if (previewTimer) clearTimeout(previewTimer);
5950        previewTimer = setTimeout(loadPreview, 280);
5951      }
5952
5953      if (pathInput) {
5954        pathInput.addEventListener("input", onPathChange);
5955      }
5956
5957      if (outputDirInput) {
5958        outputDirInput.addEventListener("input", function () {
5959          outputDirInput.dataset.userEdited = "1";
5960          syncStripOutputRoot();
5961          updateReview();
5962        });
5963      }
5964
5965      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
5966        if (!node) return;
5967        node.addEventListener("input", function () {
5968          updateReview();
5969          if (previewTimer) clearTimeout(previewTimer);
5970          previewTimer = setTimeout(loadPreview, 280);
5971        });
5972      });
5973
5974      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
5975        var node = document.getElementById(id);
5976        if (node) node.addEventListener("change", updateReview);
5977      });
5978
5979      if (reportTitleInput) {
5980        reportTitleInput.addEventListener("input", function () {
5981          reportTitleTouched = reportTitleInput.value.trim().length > 0;
5982          updateReportTitleFromPath();
5983          updateReview();
5984        });
5985      }
5986
5987      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
5988      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
5989      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); });
5990      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
5991
5992      artifactCards.forEach(function (card) {
5993        card.addEventListener("click", function () {
5994          toggleArtifactCard(card);
5995          updateReview();
5996        });
5997      });
5998
5999      if (form && loading && submitButton) {
6000        form.addEventListener("submit", function () {
6001          submitButton.disabled = true;
6002          submitButton.textContent = "Scanning...";
6003          loading.classList.add("active");
6004        });
6005      }
6006
6007      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
6008        btn.addEventListener('click', function () {
6009          var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
6010          if (!folder) return;
6011          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
6012        });
6013      });
6014
6015      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
6016      if (wsOutputLink) {
6017        wsOutputLink.addEventListener('click', function () {
6018          var folder = wsOutputLink.dataset.folder || '';
6019          if (!folder) return;
6020          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
6021        });
6022      }
6023
6024      loadSavedTheme();
6025      updateMixedPolicyUI();
6026      updatePythonDocstringUI();
6027      applyScanPreset();
6028      updatePresetDescriptions();
6029      applyArtifactPreset();
6030      updateReview();
6031      updateScrollProgress(); // initialise bar to 0% (step 1)
6032      window.addEventListener("scroll", updateScrollProgress, { passive: true });
6033      onPathChange();         // seed output dir, history badge, and preview from initial path
6034      loadPreview();
6035      updateStepNav(1);
6036
6037      // Restore step from URL hash on initial load (e.g., back-forward cache)
6038      (function() {
6039        var hashMatch = location.hash.match(/^#step([1-4])$/);
6040        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
6041      })();
6042
6043      (function randomizeWatermarks() {
6044        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
6045        if (!wms.length) return;
6046        var placed = [];
6047        function tooClose(top, left) {
6048          for (var i = 0; i < placed.length; i++) {
6049            var dt = Math.abs(placed[i][0] - top);
6050            var dl = Math.abs(placed[i][1] - left);
6051            if (dt < 16 && dl < 12) return true;
6052          }
6053          return false;
6054        }
6055        function pick(leftBand) {
6056          for (var attempt = 0; attempt < 50; attempt++) {
6057            var top = Math.random() * 88 + 2;
6058            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6059            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
6060          }
6061          var top = Math.random() * 88 + 2;
6062          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6063          placed.push([top, left]);
6064          return [top, left];
6065        }
6066        var half = Math.floor(wms.length / 2);
6067        wms.forEach(function (img, i) {
6068          var pos = pick(i < half);
6069          var size = Math.floor(Math.random() * 80 + 110);
6070          var rot = (Math.random() * 360).toFixed(1);
6071          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
6072          img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot + "deg);opacity:" + op + ";";
6073        });
6074      })();
6075
6076      (function spawnCodeParticles() {
6077        var container = document.getElementById('code-particles');
6078        if (!container) return;
6079        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'];
6080        for (var i = 0; i < 38; i++) {
6081          (function(idx) {
6082            var el = document.createElement('span');
6083            el.className = 'code-particle';
6084            el.textContent = snippets[idx % snippets.length];
6085            var left = Math.random() * 94 + 2;
6086            var top = Math.random() * 88 + 6;
6087            var dur = (Math.random() * 10 + 9).toFixed(1);
6088            var delay = (Math.random() * 18).toFixed(1);
6089            var rot = (Math.random() * 26 - 13).toFixed(1);
6090            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6091            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
6092            container.appendChild(el);
6093          })(i);
6094        }
6095      })();
6096    })();
6097  </script>
6098  <script nonce="{{ csp_nonce }}">
6099    (function () {
6100      var raw = {{ prefill_json|safe }};
6101      if (!raw || typeof raw !== 'object' || !raw.path) return;
6102      function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
6103      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
6104      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
6105      setVal('path-input', raw.path || '');
6106      setVal('include-globs', raw.include_globs || '');
6107      setVal('exclude-globs', raw.exclude_globs || '');
6108      setVal('output-dir', raw.output_dir || '');
6109      setVal('report-title', raw.report_title || '');
6110      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
6111      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
6112      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
6113      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
6114      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
6115      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
6116      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
6117      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
6118      setChecked('generate-html', raw.generate_html !== false);
6119      setChecked('generate-pdf', !!raw.generate_pdf);
6120      // Trigger dynamic UI updates after pre-fill.
6121      setTimeout(function () {
6122        var pathEl = document.getElementById('path-input');
6123        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
6124        var policyEl = document.getElementById('mixed-line-policy');
6125        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
6126      }, 80);
6127    })();
6128  </script>
6129  <footer class="site-footer">
6130    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
6131    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6132    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6133    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6134  </footer>
6135</body>
6136</html>
6137"##,
6138    ext = "html"
6139)]
6140struct IndexTemplate {
6141    version: &'static str,
6142    prefill_json: String,
6143    csp_nonce: String,
6144}
6145
6146// ── SplashTemplate ────────────────────────────────────────────────────────────
6147
6148#[derive(Template)]
6149#[template(
6150    source = r##"
6151<!doctype html>
6152<html lang="en">
6153<head>
6154  <meta charset="utf-8">
6155  <meta name="viewport" content="width=device-width, initial-scale=1">
6156  <title>OxideSLOC — Source Line Analysis Workbench</title>
6157  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6158  <style nonce="{{ csp_nonce }}">
6159    :root {
6160      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
6161      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6162      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
6163      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6164      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
6165    }
6166    body.dark-theme {
6167      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
6168      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
6169    }
6170    *{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);}
6171    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6172    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
6173    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6174    .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;}
6175    @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));}}
6176    .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);}
6177    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
6178    .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));}
6179    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
6180    .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;}
6181    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
6182    .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;}
6183    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
6184    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
6185    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
6186    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
6187    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
6188    .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;}
6189    .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;}
6190    .page{max-width:1100px;margin:0 auto;padding:48px 24px 60px;position:relative;z-index:1;}
6191    .hero{text-align:center;margin-bottom:52px;}
6192    .hero-logo{width:88px;height:97px;object-fit:contain;margin-bottom:20px;filter:drop-shadow(0 8px 22px rgba(184,93,51,0.30));animation:logoBob 3.6s ease-in-out infinite;}
6193    @keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
6194    .hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
6195      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
6196      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
6197      animation:titleShimmer 4s linear infinite;}
6198    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
6199    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;}
6200    .hero-subtitle{font-size:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
6201    @keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
6202    .action-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:32px;}
6203    @media(max-width:760px){.action-grid{grid-template-columns:1fr 1fr;}}
6204    @media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
6205    .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;}
6206    .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;}
6207    @keyframes cardRise{from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:translateY(0);}}
6208    .action-card:hover{transform:translateY(-6px) scale(1.012);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
6209    .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);}
6210    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
6211    .action-card-icon svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;}
6212    .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);}
6213    .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);}
6214    .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);}
6215    .action-card-title{font-size:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
6216    .action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
6217    .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;}
6218    body.dark-theme .action-card-cta{color:var(--oxide);}
6219    .action-card.view .action-card-cta{color:var(--accent-2);}
6220    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
6221    .action-card.compare .action-card-cta{color:#7c3aed;}
6222    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
6223    .action-card:hover .action-card-cta{gap:12px;}
6224    .divider{height:1px;background:var(--line);margin:40px 0;}
6225    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
6226    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
6227    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
6228    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;text-align:center;position:relative;cursor:default;
6229      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
6230    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
6231    .info-chip-val{font-size:22px;font-weight:900;color:var(--oxide);}
6232    body.dark-theme .info-chip-val{color:var(--oxide);}
6233    .info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
6234    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
6235      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
6236      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
6237    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
6238      border:6px solid transparent;border-top-color:var(--text);}
6239    .info-chip:hover .info-chip-tip{display:block;}
6240    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
6241    .site-footer a{color:var(--muted);}
6242  </style>
6243</head>
6244<body>
6245  <div class="background-watermarks" aria-hidden="true">
6246    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6247    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6248    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6249    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6250    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6251    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6252    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6253  </div>
6254  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6255  <div class="top-nav">
6256    <div class="top-nav-inner">
6257      <a class="brand" href="/">
6258        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6259        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
6260      </a>
6261      <div class="nav-right">
6262        <a class="nav-pill" href="/">Home</a>
6263        <a class="nav-pill" href="/view-reports">View Reports</a>
6264        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6265        <div class="server-status-wrap">
6266          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
6267          <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>
6268        </div>
6269        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6270          <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>
6271          <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>
6272        </button>
6273      </div>
6274    </div>
6275  </div>
6276
6277  <div class="page">
6278    <div class="hero">
6279      <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
6280      <h1 class="hero-title">OxideSLOC</h1>
6281      <p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
6282    </div>
6283
6284    <div class="action-grid">
6285      <a class="action-card scan" href="/scan-setup">
6286        <div class="action-card-icon">
6287          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
6288        </div>
6289        <div class="action-card-title">Scan Project</div>
6290        <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>
6291        <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>
6292      </a>
6293
6294      <a class="action-card view" href="/view-reports">
6295        <div class="action-card-icon">
6296          <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
6297        </div>
6298        <div class="action-card-title">View Reports</div>
6299        <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>
6300        <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>
6301      </a>
6302
6303      <a class="action-card compare" href="/compare-scans">
6304        <div class="action-card-icon">
6305          <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>
6306        </div>
6307        <div class="action-card-title">Compare Scans</div>
6308        <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>
6309        <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>
6310      </a>
6311    </div>
6312
6313    <div class="divider"></div>
6314
6315    <div class="info-strip">
6316      <div class="info-chip">
6317        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
6318        <div class="info-chip-val">41</div>
6319        <div class="info-chip-label">Languages</div>
6320      </div>
6321      <div class="info-chip">
6322        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
6323        <div class="info-chip-val">100%</div>
6324        <div class="info-chip-label">Self-contained</div>
6325      </div>
6326      <div class="info-chip">
6327        <div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
6328        <div class="info-chip-val">HTML</div>
6329        <div class="info-chip-label">Exportable reports</div>
6330      </div>
6331      <div class="info-chip">
6332        <div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
6333        <div class="info-chip-val">Git</div>
6334        <div class="info-chip-label">Submodule support</div>
6335      </div>
6336      <div class="info-chip">
6337        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
6338        <div class="info-chip-val">IEEE</div>
6339        <div class="info-chip-label">1045-1992</div>
6340      </div>
6341    </div>
6342  </div>
6343
6344  <footer class="site-footer">
6345    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
6346    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6347    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6348    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6349  </footer>
6350
6351  <script nonce="{{ csp_nonce }}">
6352    (function () {
6353      var storageKey = 'oxide-sloc-theme';
6354      var body = document.body;
6355      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
6356      var toggle = document.getElementById('theme-toggle');
6357      if (toggle) toggle.addEventListener('click', function () {
6358        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
6359        body.classList.toggle('dark-theme', next === 'dark');
6360        try { localStorage.setItem(storageKey, next); } catch(e) {}
6361      });
6362      (function randomizeWatermarks() {
6363        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6364        if (!wms.length) return;
6365        var placed = [];
6366        function tooClose(top, left) {
6367          for (var i = 0; i < placed.length; i++) {
6368            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
6369            if (dt < 16 && dl < 12) return true;
6370          }
6371          return false;
6372        }
6373        function pick(leftBand) {
6374          for (var attempt = 0; attempt < 50; attempt++) {
6375            var top = Math.random() * 88 + 2;
6376            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6377            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
6378          }
6379          var top = Math.random() * 88 + 2;
6380          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6381          placed.push([top, left]); return [top, left];
6382        }
6383        var half = Math.floor(wms.length / 2);
6384        wms.forEach(function (img, i) {
6385          var pos = pick(i < half);
6386          var size = Math.floor(Math.random() * 100 + 120);
6387          var rot = (Math.random() * 360).toFixed(1);
6388          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
6389          img.style.cssText = 'width:' + size + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
6390        });
6391      })();
6392
6393      (function spawnCodeParticles() {
6394        var container = document.getElementById('code-particles');
6395        if (!container) return;
6396        var snippets = [
6397          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
6398          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
6399          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
6400          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
6401          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
6402        ];
6403        var count = 38;
6404        for (var i = 0; i < count; i++) {
6405          (function(idx) {
6406            var el = document.createElement('span');
6407            el.className = 'code-particle';
6408            var text = snippets[idx % snippets.length];
6409            el.textContent = text;
6410            var left = Math.random() * 94 + 2;
6411            var top = Math.random() * 88 + 6;
6412            var dur = (Math.random() * 10 + 9).toFixed(1);
6413            var delay = (Math.random() * 18).toFixed(1);
6414            var rot = (Math.random() * 26 - 13).toFixed(1);
6415            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6416            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;'
6417              + '--rot:' + rot + 'deg;--op:' + op + ';'
6418              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
6419            container.appendChild(el);
6420          })(i);
6421        }
6422      })();
6423    })();
6424  </script>
6425</body>
6426</html>
6427"##,
6428    ext = "html"
6429)]
6430struct SplashTemplate {
6431    csp_nonce: String,
6432}
6433
6434// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
6435
6436#[derive(Template)]
6437#[template(
6438    source = r##"
6439<!doctype html>
6440<html lang="en">
6441<head>
6442  <meta charset="utf-8">
6443  <meta name="viewport" content="width=device-width, initial-scale=1">
6444  <title>OxideSLOC — Start a Scan</title>
6445  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6446  <style nonce="{{ csp_nonce }}">
6447    :root {
6448      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
6449      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
6450      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
6451      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
6452      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
6453    }
6454    body.dark-theme {
6455      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
6456      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
6457    }
6458    *{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);}
6459    .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);}
6460    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
6461    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
6462    .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));}
6463    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
6464    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
6465    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
6466    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
6467    .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;}
6468    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
6469    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
6470    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
6471    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
6472    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
6473    .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
6474    .page-header{text-align:center;margin-bottom:32px;}
6475    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
6476    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
6477    .breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
6478    .breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
6479    .breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
6480    /* Cards */
6481    .option-grid{display:flex;flex-direction:column;gap:16px;}
6482    .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;}
6483    .option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
6484    /* Two-column layout inside each card */
6485    .card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
6486    .card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
6487    .option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
6488    .option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
6489    .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
6490    .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
6491    .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
6492    .card-text{min-width:0;}
6493    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
6494    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
6495    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
6496    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
6497    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
6498    /* Right CTA column */
6499    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
6500    .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;}
6501    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
6502    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
6503    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
6504    body.dark-theme .btn-secondary{color:var(--oxide);}
6505    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
6506    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
6507    /* File input overlay — must be full-width so it aligns with other card-right buttons */
6508    .file-input-wrap{position:relative;width:100%;}
6509    .file-input-wrap .btn{width:100%;}
6510    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
6511    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6512    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
6513    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6514    .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;}
6515    @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));}}
6516    /* Recent list (card 3 — full-width section below header) */
6517    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
6518    .recent-list{display:flex;flex-direction:column;gap:8px;}
6519    .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;}
6520    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
6521    .recent-item-info{flex:1;min-width:0;}
6522    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
6523    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
6524    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
6525    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
6526    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
6527    .site-footer a{color:var(--muted);}
6528    @media(max-width:680px){
6529      .card-body{grid-template-columns:1fr;}
6530      .card-right{flex-direction:row;flex-wrap:wrap;}
6531      .btn{flex:1;}
6532    }
6533  </style>
6534</head>
6535<body>
6536  <div class="background-watermarks" aria-hidden="true">
6537    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6538    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6539    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6540    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6541    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6542    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6543    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6544  </div>
6545  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6546  <div class="top-nav">
6547    <div class="top-nav-inner">
6548      <a class="brand" href="/">
6549        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6550        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
6551      </a>
6552      <div class="nav-right">
6553        <a class="nav-pill" href="/">Home</a>
6554        <a class="nav-pill" href="/view-reports">View Reports</a>
6555        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6556        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6557          <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>
6558          <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>
6559        </button>
6560      </div>
6561    </div>
6562  </div>
6563
6564  <div class="page">
6565    <div class="breadcrumb">
6566      <a href="/">Home</a>
6567      <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6568      <span>Scan Setup</span>
6569    </div>
6570
6571    <div class="page-header">
6572      <h1>How would you like to scan?</h1>
6573      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
6574    </div>
6575
6576    <div class="option-grid">
6577
6578      <!-- Option 1: New scan -->
6579      <div class="option-card">
6580        <div class="card-body">
6581          <div class="card-left">
6582            <div class="option-icon new-scan">
6583              <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
6584            </div>
6585            <div class="card-text">
6586              <div class="option-title">Start a new scan</div>
6587              <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>
6588              <ul class="feature-list">
6589                <li>Live project scope preview before you run</li>
6590                <li>4 line-counting modes with interactive examples</li>
6591                <li>HTML, PDF, and JSON output — your choice</li>
6592                <li>IEEE 1045-1992 compliant physical SLOC counting</li>
6593              </ul>
6594            </div>
6595          </div>
6596          <div class="card-right">
6597            <a class="btn btn-primary" href="/scan">
6598              Configure &amp; scan
6599              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6600            </a>
6601            <p class="card-tip">Full 4-step setup · all options</p>
6602          </div>
6603        </div>
6604      </div>
6605
6606      <!-- Option 2: Load from config file -->
6607      <div class="option-card">
6608        <div class="card-body">
6609          <div class="card-left">
6610            <div class="option-icon load-config">
6611              <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>
6612            </div>
6613            <div class="card-text">
6614              <div class="option-title">Load a saved config</div>
6615              <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>
6616              <ul class="feature-list">
6617                <li>All 15 settings restored from the file</li>
6618                <li>Fully editable — change path or output dir</li>
6619                <li>Works with any scan-config.json</li>
6620              </ul>
6621            </div>
6622          </div>
6623          <div class="card-right">
6624            <div class="file-input-wrap">
6625              <button class="btn btn-secondary" id="load-config-btn" type="button">
6626                <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>
6627                Choose config file
6628              </button>
6629              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
6630            </div>
6631            <p class="card-tip" id="config-file-name">Exported after every scan</p>
6632          </div>
6633        </div>
6634      </div>
6635
6636      <!-- Option 3: Re-scan recent project -->
6637      <div class="option-card" id="recent-card">
6638        <div class="card-body">
6639          <div class="card-left" style="grid-column:1/-1;">
6640            <div class="option-icon rescan">
6641              <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>
6642            </div>
6643            <div class="card-text">
6644              <div class="option-title">Re-scan a recent project</div>
6645              <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>
6646              <ul class="feature-list">
6647                <li>All 15+ settings restored from the saved config</li>
6648                <li>Path and output dir are editable before running</li>
6649                <li>Only scans with a saved config appear here</li>
6650              </ul>
6651            </div>
6652          </div>
6653        </div>
6654        <div class="section-divider"></div>
6655        <div class="recent-list" id="recent-list">
6656          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
6657        </div>
6658      </div>
6659
6660    </div>
6661  </div>
6662
6663  <footer class="site-footer">
6664    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
6665    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6666    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6667    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6668  </footer>
6669
6670  <script nonce="{{ csp_nonce }}">
6671    (function () {
6672      var storageKey = 'oxide-sloc-theme';
6673      var body = document.body;
6674      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
6675      var toggle = document.getElementById('theme-toggle');
6676      if (toggle) toggle.addEventListener('click', function () {
6677        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
6678        body.classList.toggle('dark-theme', next === 'dark');
6679        try { localStorage.setItem(storageKey, next); } catch(e) {}
6680      });
6681
6682      (function randomizeWatermarks() {
6683        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6684        if (!wms.length) return;
6685        var placed = [];
6686        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; }
6687        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]; }
6688        var half = Math.floor(wms.length / 2);
6689        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.cssText = 'width:' + size + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';'; });
6690      })();
6691      (function spawnCodeParticles() {
6692        var container = document.getElementById('code-particles');
6693        if (!container) return;
6694        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'];
6695        var count = 38;
6696        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.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;'; container.appendChild(el); })(i); }
6697      })();
6698
6699      // Recent scans data injected from server
6700      var recentScans = {{ recent_scans_json|safe }};
6701
6702      function configToParams(cfg) {
6703        var p = new URLSearchParams();
6704        p.set('prefilled', '1');
6705        if (cfg.path) p.set('path', cfg.path);
6706        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
6707        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
6708        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
6709        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
6710        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
6711        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
6712        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
6713        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
6714        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
6715        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
6716        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
6717        if (cfg.report_title) p.set('report_title', cfg.report_title);
6718        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
6719        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
6720        return p;
6721      }
6722
6723      // Build recent scan list (capped at 3 visible entries)
6724      var list = document.getElementById('recent-list');
6725      var noNote = document.getElementById('no-recent-note');
6726      var hasAny = false;
6727      var MAX_RECENT = 3;
6728      if (Array.isArray(recentScans)) {
6729        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
6730        var shown = 0;
6731        validEntries.forEach(function (entry) {
6732          if (shown >= MAX_RECENT) return;
6733          shown++;
6734          hasAny = true;
6735          var item = document.createElement('div');
6736          item.className = 'recent-item';
6737          item.title = 'Restore all settings and open wizard';
6738          item.innerHTML =
6739            '<div class="recent-item-info">' +
6740              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
6741              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
6742            '</div>' +
6743            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
6744          item.addEventListener('click', function () {
6745            var params = configToParams(entry.config);
6746            window.location.href = '/scan?' + params.toString();
6747          });
6748          list.appendChild(item);
6749        });
6750        if (validEntries.length > MAX_RECENT) {
6751          var moreEl = document.createElement('div');
6752          moreEl.className = 'recent-more-link';
6753          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
6754          list.appendChild(moreEl);
6755        }
6756      }
6757      if (hasAny && noNote) noNote.style.display = 'none';
6758
6759      // Config file loader
6760      var fileInput = document.getElementById('config-file-input');
6761      var fileName = document.getElementById('config-file-name');
6762      if (fileInput) {
6763        fileInput.addEventListener('change', function () {
6764          var file = fileInput.files && fileInput.files[0];
6765          if (!file) return;
6766          if (fileName) fileName.textContent = '✓ ' + file.name;
6767          var reader = new FileReader();
6768          reader.onload = function (e) {
6769            try {
6770              var cfg = JSON.parse(e.target.result);
6771              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
6772              var params = configToParams(cfg);
6773              window.location.href = '/scan?' + params.toString();
6774            } catch (err) {
6775              alert('Could not parse config file: ' + err.message);
6776            }
6777          };
6778          reader.readAsText(file);
6779        });
6780      }
6781
6782      function escHtml(s) {
6783        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
6784      }
6785    })();
6786  </script>
6787</body>
6788</html>
6789"##,
6790    ext = "html"
6791)]
6792struct ScanSetupTemplate {
6793    recent_scans_json: String,
6794    csp_nonce: String,
6795}
6796
6797#[derive(Template)]
6798#[template(
6799    source = r##"
6800<!doctype html>
6801<html lang="en">
6802<head>
6803  <meta charset="utf-8">
6804  <meta name="viewport" content="width=device-width, initial-scale=1">
6805  <title>OxideSLOC | {{ report_title }} | Report</title>
6806  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6807  <style nonce="{{ csp_nonce }}">
6808    :root {
6809      --radius: 18px;
6810      --bg: #f5efe8;
6811      --surface: rgba(255,255,255,0.82);
6812      --surface-2: #fbf7f2;
6813      --surface-3: #efe6dc;
6814      --line: #e6d0bf;
6815      --line-strong: #dcb89f;
6816      --text: #43342d;
6817      --muted: #7b675b;
6818      --muted-2: #a08777;
6819      --nav: #b85d33;
6820      --nav-2: #7a371b;
6821      --accent: #6f9bff;
6822      --accent-2: #4a78ee;
6823      --oxide: #d37a4c;
6824      --oxide-2: #b35428;
6825      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
6826      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
6827      --success-bg: #e8f5ed;
6828      --success-text: #1a8f47;
6829      --info-bg: #eef3ff;
6830      --info-text: #4467d8;
6831    }
6832
6833    body.dark-theme {
6834      --bg: #1b1511;
6835      --surface: #261c17;
6836      --surface-2: #2d221d;
6837      --surface-3: #372922;
6838      --line: #524238;
6839      --line-strong: #6c5649;
6840      --text: #f5ece6;
6841      --muted: #c7b7aa;
6842      --muted-2: #aa9485;
6843      --nav: #b85d33;
6844      --nav-2: #7a371b;
6845      --accent: #6f9bff;
6846      --accent-2: #4a78ee;
6847      --oxide: #d37a4c;
6848      --oxide-2: #b35428;
6849      --shadow: 0 18px 42px rgba(0,0,0,0.28);
6850      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
6851      --success-bg: #163927;
6852      --success-text: #8fe2a8;
6853      --info-bg: #1c2847;
6854      --info-text: #a9c1ff;
6855    }
6856
6857    * { box-sizing: border-box; }
6858    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); }
6859    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
6860    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
6861    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
6862    .top-nav, .page { position: relative; z-index: 2; }
6863    .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); }
6864    .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; }
6865    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
6866    .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)); }
6867    .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; }
6868    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
6869    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
6870    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
6871    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
6872    .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; }
6873    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
6874    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
6875    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
6876    .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); }
6877    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
6878    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
6879    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
6880    .theme-toggle .icon-sun { display:none; }
6881    body.dark-theme .theme-toggle .icon-sun { display:block; }
6882    body.dark-theme .theme-toggle .icon-moon { display:none; }
6883    .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; }
6884    .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;}
6885    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
6886    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
6887    .hero, .panel { padding: 22px; }
6888    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
6889    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
6890    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
6891    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
6892    .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; }
6893    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
6894    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
6895    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
6896    .delta-chip.pos { background:#e6f4ea; color:#1e7e34; }
6897    .delta-chip.neg { background:#fde8e8; color:#b91c1c; }
6898    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
6899    .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 12px; text-align:center; min-width:80px; }
6900    .delta-card-val { font-size:16px; font-weight:800; }
6901    .delta-card-val.pos { color:#1e7e34; }
6902    .delta-card-val.neg { color:#b91c1c; }
6903    .delta-card-val.mod { color:#b35428; }
6904    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
6905    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
6906    .compare-ts { font-size:13px; color:var(--muted); }
6907    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
6908    .compare-arrow { color: var(--muted); }
6909    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
6910    .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; }
6911    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
6912    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
6913    .button, .copy-button {
6914      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;
6915    }
6916    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
6917    .path-list { display: grid; grid-template-columns: 1fr 0.6fr 1.4fr; gap: 10px; margin-top: 18px; }
6918    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
6919    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
6920    .path-item strong { display: block; margin-bottom: 6px; }
6921    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
6922    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
6923    .path-subitem { flex: 1; }
6924    .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); }
6925    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); }
6926    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
6927    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
6928    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
6929    th:first-child, td:first-child { width: 28%; }
6930    th { color: var(--muted); font-weight: 700; }
6931    tr:last-child td { border-bottom: none; }
6932    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
6933    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
6934    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
6935    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
6936    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
6937    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
6938    .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; }
6939    .soft-chip.success { background: var(--success-bg); color: var(--success-text); }
6940    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
6941    .muted { color: var(--muted); }
6942    .site-footer { position: relative; z-index: 2; margin-top: 24px; padding: 20px 24px; border-top: 1px solid var(--line); background: rgba(0,0,0,0.04); text-align: center; color: var(--muted); font-size: 13px; line-height: 1.7; }
6943    .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
6944    .site-footer a:hover { color: var(--text); text-decoration: underline; }
6945    .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; }
6946    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
6947    .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; }
6948    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
6949    /* Submodule panel */
6950    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6951    /* Metrics tables stack */
6952    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
6953    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
6954    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
6955    .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)); }
6956    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
6957    /* Metrics table */
6958    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
6959    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
6960    .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; }
6961    .metrics-table thead th:not(:first-child) { text-align: right; }
6962    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
6963    .metrics-table tbody tr:last-child td { border-bottom: none; }
6964    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
6965    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
6966    .metrics-table tbody tr:hover td { background: var(--surface-2); }
6967    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
6968    .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; }
6969    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
6970    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
6971    .mt-val-pos { color: #1e7e34; font-weight: 700; }
6972    .mt-val-neg { color: #b91c1c; font-weight: 700; }
6973    .mt-val-zero { color: var(--muted); }
6974    .mt-val-mod { color: var(--oxide-2); }
6975    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
6976    @media (max-width: 1180px) {
6977      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
6978      .nav-project-slot, .nav-status { justify-content:flex-start; }
6979      .hero-top { flex-direction: column; }
6980    }
6981    .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;}
6982    @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));}}
6983  </style>
6984</head>
6985<body>
6986  <div class="background-watermarks" aria-hidden="true">
6987    <img src="/images/logo/logo-text.png" alt="" />
6988    <img src="/images/logo/logo-text.png" alt="" />
6989    <img src="/images/logo/logo-text.png" alt="" />
6990    <img src="/images/logo/logo-text.png" alt="" />
6991    <img src="/images/logo/logo-text.png" alt="" />
6992    <img src="/images/logo/logo-text.png" alt="" />
6993    <img src="/images/logo/logo-text.png" alt="" />
6994    <img src="/images/logo/logo-text.png" alt="" />
6995    <img src="/images/logo/logo-text.png" alt="" />
6996    <img src="/images/logo/logo-text.png" alt="" />
6997    <img src="/images/logo/logo-text.png" alt="" />
6998    <img src="/images/logo/logo-text.png" alt="" />
6999    <img src="/images/logo/logo-text.png" alt="" />
7000    <img src="/images/logo/logo-text.png" alt="" />
7001  </div>
7002  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7003  <div class="top-nav">
7004    <div class="top-nav-inner">
7005      <a class="brand" href="/">
7006        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
7007        <div class="brand-copy">
7008          <div class="brand-title">OxideSLOC</div>
7009          <div class="brand-subtitle">Local analysis workbench</div>
7010        </div>
7011      </a>
7012      <div class="nav-project-slot">
7013        <div class="nav-project-pill"><span class="nav-project-label">Project</span><span class="nav-project-value">{{ report_title }}</span></div>
7014      </div>
7015      <div class="nav-status">
7016        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
7017        <a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
7018        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
7019        <div class="server-status-wrap">
7020          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7021          <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>
7022        </div>
7023        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
7024          <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>
7025          <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>
7026        </button>
7027      </div>
7028    </div>
7029  </div>
7030
7031  <div class="page">
7032    <section class="hero">
7033      <div class="hero-top">
7034        <div>
7035          <div class="soft-chip success">Run finished successfully</div>
7036          <h1 class="hero-title">{{ report_title }}</h1>
7037          <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>
7038        </div>
7039        <div class="hero-quick-actions">
7040          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
7041          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
7042          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
7043        </div>
7044      </div>
7045
7046      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
7047      <div class="compare-banner">
7048        <div class="compare-banner-body">
7049          <div class="compare-banner-meta">
7050            <span class="compare-label">Previous scan</span>
7051            <span class="compare-ts">{{ prev_ts }}</span>
7052            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
7053            {% if let Some(prev_code) = prev_run_code_lines %}
7054            <div class="compare-banner-stats" style="margin-top:4px;">
7055              <span>Code before: <strong>{{ prev_code }}</strong></span>
7056              <span class="compare-arrow">→</span>
7057              <span>Code now: <strong>{{ code_lines }}</strong></span>
7058              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
7059              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
7060            </div>
7061            {% endif %}
7062          </div>
7063          {% if delta_lines_added.is_some() %}
7064          <div class="delta-cards-inline">
7065            <div class="delta-card-inline">
7066              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
7067              <div class="delta-card-lbl">lines added</div>
7068            </div>
7069            <div class="delta-card-inline">
7070              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
7071              <div class="delta-card-lbl">lines removed</div>
7072            </div>
7073            <div class="delta-card-inline">
7074              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
7075              <div class="delta-card-lbl">unmodified lines</div>
7076            </div>
7077            <div class="delta-card-inline">
7078              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
7079              <div class="delta-card-lbl">files modified</div>
7080            </div>
7081            <div class="delta-card-inline">
7082              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
7083              <div class="delta-card-lbl">files added</div>
7084            </div>
7085            <div class="delta-card-inline">
7086              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
7087              <div class="delta-card-lbl">files removed</div>
7088            </div>
7089            <div class="delta-card-inline">
7090              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
7091              <div class="delta-card-lbl">files unchanged</div>
7092            </div>
7093          </div>
7094          {% else %}
7095          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
7096            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
7097          </p>
7098          {% endif %}
7099          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
7100        </div>
7101      </div>
7102      {% endif %}{% endif %}
7103
7104      <div class="action-grid">
7105        <div class="action-card">
7106          <h3>HTML report</h3>
7107          <div class="action-buttons">
7108            {% match html_url %}
7109              {% when Some with (url) %}
7110                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
7111              {% when None %}{% endmatch %}
7112            {% match html_download_url %}
7113              {% when Some with (url) %}
7114                <a class="button secondary" href="{{ url }}">Download HTML</a>
7115              {% when None %}{% endmatch %}
7116            {% match html_path %}
7117              {% when Some with (_path) %}{% when None %}{% endmatch %}
7118          </div>
7119        </div>
7120        <div class="action-card">
7121          <h3>PDF report</h3>
7122          <div class="action-buttons">
7123            {% match pdf_url %}
7124              {% when Some with (url) %}
7125                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open PDF</a>
7126              {% when None %}{% endmatch %}
7127            {% match pdf_download_url %}
7128              {% when Some with (url) %}
7129                <a class="button secondary" href="{{ url }}">Download PDF</a>
7130              {% when None %}{% endmatch %}
7131            {% match pdf_path %}
7132              {% when Some with (_path) %}{% when None %}{% endmatch %}
7133          </div>
7134        </div>
7135        <div class="action-card">
7136          <h3>JSON result</h3>
7137          <div class="action-buttons">
7138            {% match json_url %}
7139              {% when Some with (url) %}
7140                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
7141              {% when None %}{% endmatch %}
7142            {% match json_download_url %}
7143              {% when Some with (url) %}
7144                <a class="button secondary" href="{{ url }}">Download JSON</a>
7145              {% when None %}{% endmatch %}
7146            {% match json_path %}
7147              {% when Some with (_path) %}{% when None %}
7148                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
7149              {% endmatch %}
7150          </div>
7151        </div>
7152        <div class="action-card">
7153          <h3>Scan config</h3>
7154          <div class="action-buttons">
7155            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
7156            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
7157          </div>
7158          <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
7159        </div>
7160      </div>
7161      {% if !submodule_rows.is_empty() %}
7162      <div class="submodule-panel">
7163        <div class="toolbar-row">
7164          <div>
7165            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
7166            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
7167          </div>
7168          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
7169        </div>
7170        <div style="overflow:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
7171        <table style="width:100%;border-collapse:collapse;font-size:14px;">
7172          <thead>
7173            <tr>
7174              <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;">Submodule</th>
7175              <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;">Path</th>
7176              <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:right;">Files</th>
7177              <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:right;">Physical</th>
7178              <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:right;">Code</th>
7179              <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:right;">Comments</th>
7180              <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:right;">Blank</th>
7181              <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;">Report</th>
7182            </tr>
7183          </thead>
7184          <tbody>
7185            {% for row in submodule_rows %}
7186            <tr>
7187              <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;"><strong>{{ row.name }}</strong></td>
7188              <td style="padding:10px 14px;border-bottom:1px solid var(--line);"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
7189              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
7190              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
7191              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
7192              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
7193              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
7194              <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>
7195            </tr>
7196            {% endfor %}
7197          </tbody>
7198        </table>
7199        </div>
7200      </div>
7201      {% endif %}
7202
7203      <div class="metrics-tables-stack">
7204
7205        <div class="metrics-table-wrap">
7206          <div class="metrics-table-title">Files</div>
7207          <table class="metrics-table">
7208            <thead>
7209              <tr>
7210                <th>Metric</th>
7211                <th>This Run</th>
7212                <th>Previous</th>
7213                <th>Change</th>
7214              </tr>
7215            </thead>
7216            <tbody>
7217              <tr>
7218                <td>Files analyzed</td>
7219                <td class="mt-val-large">{{ files_analyzed }}</td>
7220                <td>{{ prev_fa_str }}</td>
7221                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
7222              </tr>
7223              <tr>
7224                <td>Files skipped</td>
7225                <td>{{ files_skipped }}</td>
7226                <td>{{ prev_fs_str }}</td>
7227                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
7228              </tr>
7229              <tr>
7230                <td>Files modified</td>
7231                <td class="mt-val-na">—</td>
7232                <td class="mt-val-na">—</td>
7233                <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>
7234              </tr>
7235              <tr>
7236                <td>Files unchanged</td>
7237                <td class="mt-val-na">—</td>
7238                <td class="mt-val-na">—</td>
7239                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
7240              </tr>
7241            </tbody>
7242          </table>
7243        </div>
7244
7245        <div class="metrics-table-wrap">
7246          <div class="metrics-table-title">Line Counts</div>
7247          <table class="metrics-table">
7248            <thead>
7249              <tr>
7250                <th>Metric</th>
7251                <th>This Run</th>
7252                <th>Previous</th>
7253                <th>Change</th>
7254              </tr>
7255            </thead>
7256            <tbody>
7257              <tr>
7258                <td>Physical lines</td>
7259                <td class="mt-val-large">{{ physical_lines }}</td>
7260                <td>{{ prev_pl_str }}</td>
7261                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
7262              </tr>
7263              <tr>
7264                <td>Code lines</td>
7265                <td class="mt-val-large">{{ code_lines }}</td>
7266                <td>{{ prev_cl_str }}</td>
7267                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
7268              </tr>
7269              <tr>
7270                <td>Comment lines</td>
7271                <td>{{ comment_lines }}</td>
7272                <td>{{ prev_cml_str }}</td>
7273                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
7274              </tr>
7275              <tr>
7276                <td>Blank lines</td>
7277                <td>{{ blank_lines }}</td>
7278                <td>{{ prev_bl_str }}</td>
7279                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
7280              </tr>
7281              <tr>
7282                <td>Mixed (separate)</td>
7283                <td>{{ mixed_lines }}</td>
7284                <td class="mt-val-na">—</td>
7285                <td class="mt-val-na">—</td>
7286              </tr>
7287            </tbody>
7288          </table>
7289        </div>
7290
7291        <div class="metrics-tables-lower">
7292          <div class="metrics-table-wrap">
7293            <div class="metrics-table-title">Code Structure</div>
7294            <table class="metrics-table">
7295              <thead>
7296                <tr>
7297                  <th>Metric</th>
7298                  <th>This Run</th>
7299                </tr>
7300              </thead>
7301              <tbody>
7302                <tr>
7303                  <td>Functions</td>
7304                  <td>{{ functions }}</td>
7305                </tr>
7306                <tr>
7307                  <td>Classes / Types</td>
7308                  <td>{{ classes }}</td>
7309                </tr>
7310                <tr>
7311                  <td>Variables</td>
7312                  <td>{{ variables }}</td>
7313                </tr>
7314                <tr>
7315                  <td>Imports</td>
7316                  <td>{{ imports }}</td>
7317                </tr>
7318              </tbody>
7319            </table>
7320          </div>
7321
7322          <div class="metrics-table-wrap">
7323            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
7324            <table class="metrics-table">
7325              <thead>
7326                <tr>
7327                  <th>Metric</th>
7328                  <th>Change</th>
7329                </tr>
7330              </thead>
7331              <tbody>
7332                <tr>
7333                  <td>Lines added</td>
7334                  <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>
7335                </tr>
7336                <tr>
7337                  <td>Lines removed</td>
7338                  <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>
7339                </tr>
7340                <tr>
7341                  <td>Lines modified (net)</td>
7342                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
7343                </tr>
7344                <tr>
7345                  <td>Lines unmodified</td>
7346                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
7347                </tr>
7348              </tbody>
7349            </table>
7350          </div>
7351        </div>
7352
7353      </div>
7354
7355      <div class="path-list">
7356        <div class="path-item">
7357          <div class="path-item-label">Project path</div>
7358          <code>{{ project_path }}</code>
7359        </div>
7360        <div class="path-item">
7361          <div class="path-item-label">Git branch</div>
7362          {% if let Some(branch) = git_branch %}
7363          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
7364          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
7365          {% else %}
7366          <code style="color:var(--muted)">—</code>
7367          {% endif %}
7368        </div>
7369        <div class="path-item path-item-split">
7370          <div class="path-subitem">
7371            <div class="path-item-label">Output folder</div>
7372            <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
7373          </div>
7374          <div class="path-subitem" style="border-top:1px solid var(--line);padding-top:8px;margin-top:8px;">
7375            <div class="path-item-label">Run ID</div>
7376            <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
7377              <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
7378              <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
7379            </div>
7380          </div>
7381        </div>
7382      </div>
7383    </section>
7384
7385    <section class="panel" style="margin-bottom: 18px;">
7386        <div class="toolbar-row">
7387          <div>
7388            <h2>Language breakdown</h2>
7389            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
7390          </div>
7391        </div>
7392        <table>
7393          <thead>
7394            <tr>
7395              <th>Language</th>
7396              <th>Files</th>
7397              <th>Physical</th>
7398              <th>Code</th>
7399              <th>Comments</th>
7400              <th>Blank</th>
7401              <th>Mixed</th>
7402              <th>Functions</th>
7403              <th>Classes</th>
7404              <th>Variables</th>
7405              <th>Imports</th>
7406            </tr>
7407          </thead>
7408          <tbody>
7409            {% for row in language_rows %}
7410            <tr>
7411              <td>{{ row.language }}</td>
7412              <td>{{ row.files }}</td>
7413              <td>{{ row.physical }}</td>
7414              <td>{{ row.code }}</td>
7415              <td>{{ row.comments }}</td>
7416              <td>{{ row.blank }}</td>
7417              <td>{{ row.mixed }}</td>
7418              <td>{{ row.functions }}</td>
7419              <td>{{ row.classes }}</td>
7420              <td>{{ row.variables }}</td>
7421              <td>{{ row.imports }}</td>
7422            </tr>
7423            {% endfor %}
7424          </tbody>
7425        </table>
7426    </section>
7427
7428  </div>
7429
7430  <script nonce="{{ csp_nonce }}">
7431    (function () {
7432      var body = document.body;
7433      var themeToggle = document.getElementById('theme-toggle');
7434      var storageKey = 'oxide-sloc-theme';
7435
7436      function applyTheme(theme) {
7437        body.classList.toggle('dark-theme', theme === 'dark');
7438      }
7439
7440      function loadSavedTheme() {
7441        try {
7442          var saved = localStorage.getItem(storageKey);
7443          if (saved === 'dark' || saved === 'light') {
7444            applyTheme(saved);
7445          }
7446        } catch (e) {}
7447      }
7448
7449      if (themeToggle) {
7450        themeToggle.addEventListener('click', function () {
7451          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
7452          applyTheme(nextTheme);
7453          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
7454        });
7455      }
7456
7457      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
7458        button.addEventListener('click', function () {
7459          var value = button.getAttribute('data-copy-value') || '';
7460          if (!value) return;
7461          if (navigator.clipboard && navigator.clipboard.writeText) {
7462            navigator.clipboard.writeText(value).catch(function () {});
7463          }
7464        });
7465      });
7466
7467      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
7468        btn.addEventListener('click', function () {
7469          var folder = btn.getAttribute('data-folder') || '';
7470          if (!folder) return;
7471          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
7472        });
7473      });
7474
7475      loadSavedTheme();
7476
7477      (function randomizeWatermarks() {
7478        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
7479        if (!wms.length) return;
7480        var placed = [];
7481        function tooClose(top, left) {
7482          for (var i = 0; i < placed.length; i++) {
7483            var dt = Math.abs(placed[i][0] - top);
7484            var dl = Math.abs(placed[i][1] - left);
7485            if (dt < 20 && dl < 18) return true;
7486          }
7487          return false;
7488        }
7489        function pick(leftBand) {
7490          for (var attempt = 0; attempt < 50; attempt++) {
7491            var top = Math.random() * 85 + 5;
7492            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
7493            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
7494          }
7495          var top = Math.random() * 85 + 5;
7496          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
7497          placed.push([top, left]);
7498          return [top, left];
7499        }
7500        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
7501        var half = Math.floor(wms.length / 2);
7502        wms.forEach(function (img, i) {
7503          var pos = pick(i < half);
7504          var size = Math.floor(Math.random() * 100 + 160);
7505          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
7506          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
7507          img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot.toFixed(1) + "deg);opacity:" + op + ";";
7508        });
7509      })();
7510
7511      (function spawnCodeParticles() {
7512        var container = document.getElementById('code-particles');
7513        if (!container) return;
7514        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'];
7515        for (var i = 0; i < 38; i++) {
7516          (function(idx) {
7517            var el = document.createElement('span');
7518            el.className = 'code-particle';
7519            el.textContent = snippets[idx % snippets.length];
7520            var left = Math.random() * 94 + 2;
7521            var top = Math.random() * 88 + 6;
7522            var dur = (Math.random() * 10 + 9).toFixed(1);
7523            var delay = (Math.random() * 18).toFixed(1);
7524            var rot = (Math.random() * 26 - 13).toFixed(1);
7525            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7526            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7527            container.appendChild(el);
7528          })(i);
7529        }
7530      })();
7531    })();
7532  </script>
7533  <footer class="site-footer">
7534    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
7535    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7536    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7537    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7538  </footer>
7539</body>
7540</html>
7541"##,
7542    ext = "html"
7543)]
7544struct ResultTemplate {
7545    report_title: String,
7546    project_path: String,
7547    output_dir: String,
7548    run_id: String,
7549    files_analyzed: u64,
7550    files_skipped: u64,
7551    physical_lines: u64,
7552    code_lines: u64,
7553    comment_lines: u64,
7554    blank_lines: u64,
7555    mixed_lines: u64,
7556    functions: u64,
7557    classes: u64,
7558    variables: u64,
7559    imports: u64,
7560    html_url: Option<String>,
7561    pdf_url: Option<String>,
7562    json_url: Option<String>,
7563    html_download_url: Option<String>,
7564    pdf_download_url: Option<String>,
7565    json_download_url: Option<String>,
7566    html_path: Option<String>,
7567    pdf_path: Option<String>,
7568    json_path: Option<String>,
7569    language_rows: Vec<LanguageSummaryRow>,
7570    prev_run_id: Option<String>,
7571    prev_run_timestamp: Option<String>,
7572    prev_run_code_lines: Option<u64>,
7573    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
7574    prev_fa_str: String,
7575    prev_fs_str: String,
7576    prev_pl_str: String,
7577    prev_cl_str: String,
7578    prev_cml_str: String,
7579    prev_bl_str: String,
7580    // Signed change column for main metrics
7581    delta_fa_str: String,
7582    delta_fa_class: String,
7583    delta_fs_str: String,
7584    delta_fs_class: String,
7585    delta_pl_str: String,
7586    delta_pl_class: String,
7587    delta_cl_str: String,
7588    delta_cl_class: String,
7589    delta_cml_str: String,
7590    delta_cml_class: String,
7591    delta_bl_str: String,
7592    delta_bl_class: String,
7593    // delta vs previous scan
7594    delta_lines_added: Option<i64>,
7595    delta_lines_removed: Option<i64>,
7596    delta_lines_net_str: String,
7597    delta_lines_net_class: String,
7598    delta_files_added: Option<usize>,
7599    delta_files_removed: Option<usize>,
7600    delta_files_modified: Option<usize>,
7601    delta_files_unchanged: Option<usize>,
7602    delta_unmodified_lines: Option<u64>,
7603    // git context
7604    git_branch: Option<String>,
7605    git_commit: Option<String>,
7606    git_author: Option<String>,
7607    // history
7608    prev_scan_count: usize,
7609    current_scan_number: usize,
7610    // submodule breakdown (empty when not requested)
7611    submodule_rows: Vec<SubmoduleRow>,
7612    scan_config_url: String,
7613    csp_nonce: String,
7614}
7615
7616#[derive(Template)]
7617#[template(
7618    source = r##"
7619<!doctype html>
7620<html lang="en">
7621<head>
7622  <meta charset="utf-8">
7623  <meta name="viewport" content="width=device-width, initial-scale=1">
7624  <title>OxideSLOC | Error</title>
7625  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7626  <style nonce="{{ csp_nonce }}">
7627    :root {
7628      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
7629      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7630      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
7631      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7632    }
7633    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7634    *{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);}
7635    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7636    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7637    .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);}
7638    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7639    .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));}
7640    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7641    .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;}
7642    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7643    .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;}
7644    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7645    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7646    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7647    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7648    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7649    .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
7650    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
7651    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
7652    .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;}
7653    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
7654    .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);}
7655    .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;}
7656    .btn-secondary:hover{background:var(--line);}
7657    .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;}
7658    .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;}
7659    .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;}
7660    @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));}}
7661  </style>
7662</head>
7663<body>
7664  <div class="background-watermarks" aria-hidden="true">
7665    <img src="/images/logo/logo-text.png" alt="" style="width:320px;top:-40px;left:-60px;transform:rotate(-12deg);" />
7666    <img src="/images/logo/logo-text.png" alt="" style="width:280px;top:120px;right:-50px;transform:rotate(8deg);" />
7667    <img src="/images/logo/logo-text.png" alt="" style="width:260px;bottom:60px;left:30px;transform:rotate(15deg);" />
7668    <img src="/images/logo/logo-text.png" alt="" style="width:300px;bottom:-20px;right:80px;transform:rotate(-6deg);" />
7669    <img src="/images/logo/logo-text.png" alt="" style="width:240px;top:50%;left:45%;transform:rotate(22deg);" />
7670    <img src="/images/logo/logo-text.png" alt="" style="width:270px;top:10%;left:35%;transform:rotate(-18deg);" />
7671  </div>
7672  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7673  <div class="top-nav">
7674    <div class="top-nav-inner">
7675      <a class="brand" href="/">
7676        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
7677        <div class="brand-copy">
7678          <div class="brand-title">OxideSLOC</div>
7679          <div class="brand-subtitle">Local analysis workbench</div>
7680        </div>
7681      </a>
7682      <div class="nav-right">
7683        <a class="nav-pill" href="/">Home</a>
7684        <a class="nav-pill" href="/view-reports">View Reports</a>
7685        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7686        <div class="server-status-wrap">
7687          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7688          <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>
7689        </div>
7690        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7691          <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>
7692          <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>
7693        </button>
7694      </div>
7695    </div>
7696  </div>
7697
7698  <div class="page">
7699    <div class="panel">
7700      <h1>Analysis failed</h1>
7701      <div class="error-box">{{ message }}</div>
7702      <div class="actions">
7703        <a class="btn-primary" href="/scan">Back to setup</a>
7704        {% if let Some(report_url) = last_report_url %}
7705        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
7706        {% endif %}
7707        <a class="btn-secondary" href="/view-reports">View Reports</a>
7708      </div>
7709    </div>
7710  </div>
7711  <script nonce="{{ csp_nonce }}">
7712    (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");});})();
7713    (function spawnCodeParticles() {
7714      var container = document.getElementById('code-particles');
7715      if (!container) return;
7716      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'];
7717      for (var i = 0; i < 38; i++) {
7718        (function(idx) {
7719          var el = document.createElement('span');
7720          el.className = 'code-particle';
7721          el.textContent = snippets[idx % snippets.length];
7722          var left = Math.random() * 94 + 2;
7723          var top = Math.random() * 88 + 6;
7724          var dur = (Math.random() * 10 + 9).toFixed(1);
7725          var delay = (Math.random() * 18).toFixed(1);
7726          var rot = (Math.random() * 26 - 13).toFixed(1);
7727          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7728          el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7729          container.appendChild(el);
7730        })(i);
7731      }
7732    })();
7733  </script>
7734</body>
7735</html>
7736"##,
7737    ext = "html"
7738)]
7739struct ErrorTemplate {
7740    message: String,
7741    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
7742    last_report_url: Option<String>,
7743    /// Label for the secondary action button; defaults to "View last report" when None.
7744    last_report_label: Option<String>,
7745    csp_nonce: String,
7746}
7747
7748// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
7749
7750#[derive(Template)]
7751#[template(
7752    source = r##"
7753<!doctype html>
7754<html lang="en">
7755<head>
7756  <meta charset="utf-8">
7757  <meta name="viewport" content="width=device-width, initial-scale=1">
7758  <title>OxideSLOC | View Reports</title>
7759  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7760  <style nonce="{{ csp_nonce }}">
7761    :root {
7762      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7763      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7764      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7765      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7766      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea;
7767    }
7768    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7769    *{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);}
7770    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7771    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7772    .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);}
7773    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7774    .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));}
7775    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7776    .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;}
7777    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7778    .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;}
7779    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7780    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7781    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7782    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7783    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7784    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7785    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7786    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7787    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7788    .panel-meta{font-size:13px;color:var(--muted);}
7789    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7790    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7791    .per-page-label{font-size:13px;color:var(--muted);}
7792    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;}
7793    .filter-input{min-width:180px;cursor:text;}
7794    .table-wrap{width:100%;overflow-x:auto;}
7795    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7796    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;}
7797    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
7798    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7799    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
7800    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7801    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
7802    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7803    tr:last-child td{border-bottom:none;}
7804    tr:hover td{background:var(--surface-2);}
7805    .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);}
7806    .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);}
7807    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7808    .metric-num{font-weight:700;color:var(--text);}
7809    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7810    .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;}
7811    .btn:hover{background:var(--line);}
7812    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7813    .btn.primary:hover{opacity:.9;}
7814    .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;}
7815    .btn-back:hover{background:var(--line);}
7816    .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;}
7817    .export-btn:hover{background:var(--line);}
7818    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
7819    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
7820    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
7821    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7822    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7823    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7824    .pagination-info{font-size:13px;color:var(--muted);}
7825    .pagination-btns{display:flex;gap:6px;}
7826    .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;}
7827    .pg-btn:hover:not(:disabled){background:var(--line);}
7828    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7829    .pg-btn:disabled{opacity:.35;cursor:default;}
7830    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7831    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7832    .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;}
7833    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7834    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7835    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7836    .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);}
7837    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7838    .stat-chip:hover .stat-chip-tip{opacity:1;}
7839    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7840    .site-footer a{color:var(--muted);}
7841    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7842    .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%;}
7843    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
7844    .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;}
7845    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
7846    .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;}
7847    .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;}
7848    .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;}
7849    @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));}}
7850  </style>
7851</head>
7852<body>
7853  <div class="background-watermarks" aria-hidden="true">
7854    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7855    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7856    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7857    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7858    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7859    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7860  </div>
7861  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7862  <div class="top-nav">
7863    <div class="top-nav-inner">
7864      <a class="brand" href="/">
7865        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7866        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
7867      </a>
7868      <div class="nav-right">
7869        <a class="nav-pill" href="/">Home</a>
7870        <a class="nav-pill" href="/view-reports">View Reports</a>
7871        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7872        <div class="server-status-wrap">
7873          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7874          <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>
7875        </div>
7876        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7877          <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>
7878          <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>
7879        </button>
7880      </div>
7881    </div>
7882  </div>
7883
7884  <div class="page">
7885    {% if linked %}
7886    <div class="toast-success">
7887      <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>
7888      Report linked successfully — it now appears in the list below.
7889    </div>
7890    {% endif %}
7891    {% if total_scans > 0 %}
7892    <div class="summary-strip">
7893      <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>
7894      <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>
7895      <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>
7896      <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>
7897    </div>
7898    {% endif %}
7899
7900    <section class="panel">
7901      <div class="panel-header">
7902        <div>
7903          <h1>View Reports</h1>
7904          <p class="panel-meta">{{ total_scans }} report(s) available. Click any row to open it.</p>
7905        </div>
7906        <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7907          <div class="export-group">
7908            <button type="button" class="export-btn" onclick="exportHistoryCsv()">
7909              <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>
7910              Export CSV
7911            </button>
7912            <button type="button" class="export-btn" onclick="exportHistoryXls()">
7913              <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>
7914              Export Excel
7915            </button>
7916          </div>
7917          <a class="btn-back" href="/">
7918            <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>
7919            Home
7920          </a>
7921        </div>
7922      </div>
7923
7924      <div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap;">
7925        <span class="locate-label" style="white-space:nowrap;">Have a saved report on disk? Browse to link it here.</span>
7926        {% if !entries.is_empty() %}
7927        <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7928          <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7929          <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7930          <button type="button" class="btn" onclick="resetView()">&#8635; Reset view</button>
7931        </div>
7932        {% endif %}
7933      </div>
7934      <div style="margin-bottom:14px;">
7935        <button type="button" class="btn" onclick="browseReport()">
7936          <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>
7937          Browse for Report…
7938        </button>
7939      </div>
7940
7941      {% if entries.is_empty() %}
7942      <div class="empty-state">
7943        <strong>No reports with viewable HTML yet</strong>
7944        Run a new analysis from the <a href="/scan">scan page</a>, or use the browse button above to link an existing report.
7945      </div>
7946      {% else %}
7947      <div class="table-wrap">
7948        <table id="history-table">
7949          <colgroup>
7950            <col style="width:155px">
7951            <col style="width:160px">
7952            <col style="width:115px">
7953            <col style="width:88px">
7954            <col style="width:88px">
7955            <col style="width:88px">
7956            <col style="width:72px">
7957            <col style="width:80px">
7958            <col style="width:76px">
7959            <col style="width:80px">
7960            <col style="width:72px">
7961            <col style="width:92px">
7962            <col style="width:92px">
7963            <col style="width:160px">
7964          </colgroup>
7965          <thead>
7966            <tr id="history-thead">
7967              <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>
7968              <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>
7969              <th>Run ID<div class="col-resize-handle"></div></th>
7970              <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>
7971              <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>
7972              <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>
7973              <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>
7974              <th>Functions<div class="col-resize-handle"></div></th>
7975              <th>Classes<div class="col-resize-handle"></div></th>
7976              <th>Variables<div class="col-resize-handle"></div></th>
7977              <th>Imports<div class="col-resize-handle"></div></th>
7978              <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>
7979              <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>
7980              <th>Report<div class="col-resize-handle"></div></th>
7981            </tr>
7982          </thead>
7983          <tbody id="history-tbody">
7984            {% for entry in entries %}
7985            <tr class="history-row" data-run="{{ entry.run_id }}"
7986                data-timestamp="{{ entry.timestamp }}"
7987                data-project="{{ entry.project_label }}"
7988                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
7989                data-skipped="{{ entry.files_skipped }}"
7990                data-comments="{{ entry.comment_lines }}"
7991                data-blank="{{ entry.blank_lines }}"
7992                data-branch="{{ entry.git_branch }}"
7993                data-commit="{{ entry.git_commit }}"
7994                style="cursor:pointer;"
7995                onclick="window.open('/runs/{{ entry.run_id }}/html', '_blank')">
7996              <td>{{ entry.timestamp }}</td>
7997              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7998              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
7999              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
8000              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
8001              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
8002              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
8003              <td><span class="metric-num">{{ entry.functions }}</span></td>
8004              <td><span class="metric-num">{{ entry.classes }}</span></td>
8005              <td><span class="metric-num">{{ entry.variables }}</span></td>
8006              <td><span class="metric-num">{{ entry.imports }}</span></td>
8007              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
8008              <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>
8009              <td style="overflow:visible;white-space:normal;">
8010                <div class="actions-cell">
8011                  <a class="btn primary" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="View HTML report">View</a>
8012                  {% if entry.has_pdf %}<a class="btn" href="/runs/{{ entry.run_id }}/pdf" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="View PDF report" style="background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;">PDF</a>{% endif %}
8013                </div>
8014              </td>
8015            </tr>
8016            {% endfor %}
8017          </tbody>
8018        </table>
8019      </div>
8020      <div class="pagination">
8021        <span class="pagination-info" id="pagination-info"></span>
8022        <div class="pagination-btns" id="pagination-btns"></div>
8023        <div style="display:flex;align-items:center;gap:8px;">
8024          <span class="per-page-label">Show</span>
8025          <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
8026            <option value="10">10 per page</option>
8027            <option value="25" selected>25 per page</option>
8028            <option value="50">50 per page</option>
8029            <option value="100">100 per page</option>
8030          </select>
8031          <span class="per-page-label" id="page-range-label"></span>
8032        </div>
8033      </div>
8034      {% endif %}
8035    </section>
8036  </div>
8037
8038  <footer class="site-footer">
8039    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
8040    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8041    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8042    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8043  </footer>
8044
8045  <script nonce="{{ csp_nonce }}">
8046    (function () {
8047      // ── Theme ──────────────────────────────────────────────────────────────
8048      var storageKey = 'oxide-sloc-theme';
8049      var body = document.body;
8050      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8051      var toggle = document.getElementById('theme-toggle');
8052      if (toggle) toggle.addEventListener('click', function () {
8053        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8054        body.classList.toggle('dark-theme', next === 'dark');
8055        try { localStorage.setItem(storageKey, next); } catch(e) {}
8056      });
8057
8058      // ── State ─────────────────────────────────────────────────────────────
8059      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
8060      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
8061      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
8062
8063      // Aggregate stats from first (most recent) row
8064      if (allRows.length) {
8065        var first = allRows[0];
8066        var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
8067        var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
8068        var se = document.getElementById('agg-skipped'); if (se) se.textContent = first.dataset.skipped;
8069      }
8070
8071      // ── Branch filter population ──────────────────────────────────────────
8072      (function() {
8073        var branches = {};
8074        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
8075        var sel = document.getElementById('branch-filter');
8076        if (sel) Object.keys(branches).sort().forEach(function(b) {
8077          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
8078        });
8079      })();
8080
8081      // ── Filter ────────────────────────────────────────────────────────────
8082      function getFilteredRows() {
8083        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
8084        var branch = ((document.getElementById('branch-filter') || {}).value || '');
8085        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
8086          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
8087          if (branch && (r.dataset.branch || '') !== branch) return false;
8088          return true;
8089        });
8090      }
8091
8092      // ── Pagination ────────────────────────────────────────────────────────
8093      function renderPage() {
8094        var filtered = getFilteredRows();
8095        var total = filtered.length;
8096        var totalPages = Math.max(1, Math.ceil(total / perPage));
8097        currentPage = Math.min(currentPage, totalPages);
8098        var start = (currentPage - 1) * perPage;
8099        var end = Math.min(start + perPage, total);
8100        var shown = {};
8101        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
8102        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
8103          r.style.display = shown[r.dataset.run] ? '' : 'none';
8104        });
8105        var rl = document.getElementById('page-range-label');
8106        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8107        var info = document.getElementById('pagination-info');
8108        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
8109        var btns = document.getElementById('pagination-btns');
8110        if (!btns) return;
8111        btns.innerHTML = '';
8112        function makeBtn(lbl, pg, active, disabled) {
8113          var b = document.createElement('button');
8114          b.className = 'pg-btn' + (active ? ' active' : '');
8115          b.textContent = lbl; b.disabled = disabled;
8116          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
8117          return b;
8118        }
8119        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
8120        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8121        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
8122        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
8123      }
8124
8125      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
8126      window.applyFilters = function() { currentPage = 1; renderPage(); };
8127
8128      // ── Sorting ───────────────────────────────────────────────────────────
8129      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
8130      function doSort(col, type, order) {
8131        var tbody = document.getElementById('history-tbody');
8132        if (!tbody) return;
8133        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
8134        rows.sort(function(a, b) {
8135          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
8136          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
8137          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8138          return va < vb ? 1 : va > vb ? -1 : 0;
8139        });
8140        rows.forEach(function(r) { tbody.appendChild(r); });
8141        currentPage = 1; renderPage();
8142      }
8143      sortHeaders.forEach(function(th) {
8144        th.addEventListener('click', function(e) {
8145          if (e.target.classList.contains('col-resize-handle')) return;
8146          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8147          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8148          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8149          th.classList.add('sort-' + sortOrder);
8150          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8151          doSort(col, type, sortOrder);
8152        });
8153      });
8154
8155      // ── Column resize ─────────────────────────────────────────────────────
8156      (function() {
8157        var table = document.getElementById('history-table');
8158        if (!table) return;
8159        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8160        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
8161        ths.forEach(function(th, i) {
8162          var handle = th.querySelector('.col-resize-handle');
8163          if (!handle || !cols[i]) return;
8164          var startX, startW;
8165          handle.addEventListener('mousedown', function(e) {
8166            e.stopPropagation(); e.preventDefault();
8167            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8168            handle.classList.add('dragging');
8169            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8170            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8171            document.addEventListener('mousemove', onMove);
8172            document.addEventListener('mouseup', onUp);
8173          });
8174        });
8175      })();
8176
8177      // ── Reset view ────────────────────────────────────────────────────────
8178      window.resetView = function() {
8179        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
8180        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
8181        sortCol = null; sortOrder = 'asc';
8182        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8183        var tbody = document.getElementById('history-tbody');
8184        if (tbody) {
8185          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
8186          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8187          rows.forEach(function(r) { tbody.appendChild(r); });
8188        }
8189        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
8190        var table = document.getElementById('history-table');
8191        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8192        currentPage = 1; renderPage();
8193      };
8194
8195      renderPage();
8196
8197      (function randomizeWatermarks() {
8198        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8199        if (!wms.length) return;
8200        var placed = [];
8201        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;}
8202        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];}
8203        var half=Math.floor(wms.length/2);
8204        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.cssText='width:'+sz+'px;top:'+pos[0].toFixed(1)+'%;left:'+pos[1].toFixed(1)+'%;transform:rotate('+rot+'deg);opacity:'+op+';';});
8205      })();
8206
8207      (function spawnCodeParticles() {
8208        var container = document.getElementById('code-particles');
8209        if (!container) return;
8210        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'];
8211        for (var i = 0; i < 38; i++) {
8212          (function(idx) {
8213            var el = document.createElement('span');
8214            el.className = 'code-particle';
8215            el.textContent = snippets[idx % snippets.length];
8216            var left = Math.random() * 94 + 2;
8217            var top = Math.random() * 88 + 6;
8218            var dur = (Math.random() * 10 + 9).toFixed(1);
8219            var delay = (Math.random() * 18).toFixed(1);
8220            var rot = (Math.random() * 26 - 13).toFixed(1);
8221            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8222            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8223            container.appendChild(el);
8224          })(i);
8225        }
8226      })();
8227    })();
8228
8229    function rowClick(runId, hasHtml) {
8230      if (hasHtml) window.open('/runs/' + runId + '/html', '_blank');
8231    }
8232
8233    function browseReport() {
8234      fetch('/pick-file?kind=report')
8235        .then(function(r) { return r.json(); })
8236        .then(function(data) {
8237          if (!data.cancelled && data.selected_path) {
8238            var form = document.createElement('form');
8239            form.method = 'POST';
8240            form.action = '/locate-report';
8241            var input = document.createElement('input');
8242            input.type = 'hidden';
8243            input.name = 'file_path';
8244            input.value = data.selected_path;
8245            form.appendChild(input);
8246            document.body.appendChild(form);
8247            form.submit();
8248          }
8249        })
8250        .catch(function(e) { alert('Could not open file picker: ' + e); });
8251    }
8252
8253    // ── Export helpers ────────────────────────────────────────────────────────
8254    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
8255    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
8256    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);}
8257    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;');}
8258    function slocXls(fname,sheet,hdrs,rows){var x='<?xml version="1.0"?><Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"><Worksheet ss:Name="'+slocEscXml(sheet)+'"><Table><Row>'+hdrs.map(function(h){return '<Cell><Data ss:Type="String">'+slocEscXml(h)+'</Data></Cell>';}).join('')+'</Row>';rows.forEach(function(r){x+='<Row>'+r.map(function(c,i){var t=(i>0&&c!==''&&!isNaN(String(c).replace(/^[+\-]/,'')))?'Number':'String';return '<Cell><Data ss:Type="'+t+'">'+slocEscXml(c)+'</Data></Cell>';}).join('')+'</Row>';});x+='</Table></Worksheet></Workbook>';slocDownload(x,fname,'application/vnd.ms-excel');}
8259
8260    var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
8261    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;}
8262    window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
8263    window.exportHistoryXls = function(){slocXls('scan-history.xls','Scan History',_hh,getHistoryRows());};
8264  </script>
8265</body>
8266</html>
8267"##,
8268    ext = "html"
8269)]
8270struct HistoryTemplate {
8271    entries: Vec<HistoryEntryRow>,
8272    total_scans: usize,
8273    linked: bool,
8274    csp_nonce: String,
8275}
8276
8277// ── CompareSelectTemplate ──────────────────────────────────────────────────────
8278
8279#[derive(Template)]
8280#[template(
8281    source = r##"
8282<!doctype html>
8283<html lang="en">
8284<head>
8285  <meta charset="utf-8">
8286  <meta name="viewport" content="width=device-width, initial-scale=1">
8287  <title>OxideSLOC | Compare Scans</title>
8288  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8289  <style nonce="{{ csp_nonce }}">
8290    :root {
8291      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
8292      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
8293      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
8294      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
8295      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
8296    }
8297    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
8298    *{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);}
8299    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8300    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8301    .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);}
8302    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
8303    .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));}
8304    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8305    .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;}
8306    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
8307    .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;}
8308    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
8309    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
8310    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8311    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8312    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8313    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
8314    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
8315    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
8316    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
8317    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
8318    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
8319    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
8320    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
8321    .per-page-label{font-size:13px;color:var(--muted);}
8322    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;}
8323    .filter-input{min-width:180px;cursor:text;}
8324    .table-wrap{width:100%;overflow-x:auto;}
8325    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
8326    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;}
8327    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--accent-2);}
8328    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
8329    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent-2);}
8330    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
8331    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(111,155,255,0.3);}
8332    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8333    tr:last-child td{border-bottom:none;}
8334    tr.selected td{background:var(--sel-bg);}
8335    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
8336    tr:hover:not(.selected) td{background:var(--surface-2);}
8337    tr{cursor:pointer;}
8338    .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);}
8339    .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);}
8340    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
8341    .metric-num{font-weight:700;}
8342    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
8343    .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;}
8344    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
8345    .btn{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;border-radius:8px;font-size:13px;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;}
8346    .btn:hover{background:var(--line);}
8347    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
8348    .btn.primary:hover{opacity:.9;}
8349    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
8350    .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;}
8351    .btn-back:hover{background:var(--line);}
8352    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
8353    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
8354    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
8355    .pagination-info{font-size:13px;color:var(--muted);}
8356    .pagination-btns{display:flex;gap:6px;}
8357    .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;}
8358    .pg-btn:hover:not(:disabled){background:var(--line);}
8359    .pg-btn.active{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
8360    .pg-btn:disabled{opacity:.35;cursor:default;}
8361    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8362    .site-footer a{color:var(--muted);}
8363    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
8364    .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;}
8365    .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;}
8366    .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;}
8367    @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));}}
8368    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
8369    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
8370    .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;}
8371    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
8372    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
8373    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
8374    .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);}
8375    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
8376    .stat-chip:hover .stat-chip-tip{opacity:1;}
8377    .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;}
8378    .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%;}
8379    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
8380  </style>
8381</head>
8382<body>
8383  <div class="background-watermarks" aria-hidden="true">
8384    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8385    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8386    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8387    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8388    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8389    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8390  </div>
8391  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8392  <div class="top-nav">
8393    <div class="top-nav-inner">
8394      <a class="brand" href="/">
8395        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8396        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
8397      </a>
8398      <div class="nav-right">
8399        <a class="nav-pill" href="/">Home</a>
8400        <a class="nav-pill" href="/view-reports">View Reports</a>
8401        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8402        <div class="server-status-wrap">
8403          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8404          <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>
8405        </div>
8406        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8407          <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>
8408          <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>
8409        </button>
8410      </div>
8411    </div>
8412  </div>
8413
8414  <div class="page">
8415    {% if total_scans > 0 %}
8416    <div class="summary-strip">
8417      <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>
8418      <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>
8419      <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>
8420      <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>
8421    </div>
8422    {% endif %}
8423    <section class="panel">
8424      <div class="panel-header">
8425        <div>
8426          <h1>Compare Scans</h1>
8427          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
8428        </div>
8429        <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
8430          <button class="btn primary" id="compare-btn" onclick="doCompare()" disabled>
8431            <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>
8432            Compare <span class="sel-count" id="sel-count">0/2</span>
8433          </button>
8434          <a class="btn-back" href="/">
8435            <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>
8436            Home
8437          </a>
8438        </div>
8439      </div>
8440
8441      {% if entries.is_empty() %}
8442      <div class="empty-state">
8443        <strong>No scans yet</strong>
8444        Run your first analysis from the <a href="/scan">scan page</a>.
8445      </div>
8446      {% else %}
8447      <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;">
8448        <div class="instruction-bar" style="margin-bottom:0;flex-shrink:0;">
8449          <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>
8450          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
8451        </div>
8452        <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
8453          <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
8454          <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
8455          <button type="button" class="btn" onclick="resetView()">&#8635; Reset view</button>
8456        </div>
8457      </div>
8458      <div class="table-wrap">
8459        <table id="compare-table">
8460          <colgroup>
8461            <col style="width:44px">
8462            <col style="width:165px">
8463            <col style="width:180px">
8464            <col style="width:110px">
8465            <col style="width:100px">
8466            <col style="width:80px">
8467            <col style="width:100px">
8468            <col style="width:90px">
8469            <col style="width:100px">
8470          </colgroup>
8471          <thead>
8472            <tr id="compare-thead">
8473              <th style="text-align:center;padding-left:8px;padding-right:8px;"><div class="col-resize-handle"></div></th>
8474              <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>
8475              <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>
8476              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
8477              <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>
8478              <th class="sortable" data-sort-col="code" data-sort-type="num">Code<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
8479              <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>
8480              <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>
8481              <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>
8482            </tr>
8483          </thead>
8484          <tbody id="compare-tbody">
8485            {% for entry in entries %}
8486            <tr class="compare-row" data-run="{{ entry.run_id }}"
8487                data-timestamp="{{ entry.timestamp }}"
8488                data-project="{{ entry.project_label }}"
8489                data-files="{{ entry.files_analyzed }}"
8490                data-code="{{ entry.code_lines }}"
8491                data-comments="{{ entry.comment_lines }}"
8492                data-branch="{{ entry.git_branch }}"
8493                data-commit="{{ entry.git_commit }}"
8494                onclick="toggleRow(this, '{{ entry.run_id }}')">
8495              <td style="text-align:center;padding-left:8px;padding-right:8px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
8496              <td>{{ entry.timestamp }}</td>
8497              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
8498              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
8499              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
8500              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
8501              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
8502              <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>
8503              <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>
8504            </tr>
8505            {% endfor %}
8506          </tbody>
8507        </table>
8508      </div>
8509      <div class="pagination">
8510        <span class="pagination-info" id="pagination-info"></span>
8511        <div class="pagination-btns" id="pagination-btns"></div>
8512        <div style="display:flex;align-items:center;gap:8px;">
8513          <span class="per-page-label">Show</span>
8514          <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
8515            <option value="10">10 per page</option>
8516            <option value="25" selected>25 per page</option>
8517            <option value="50">50 per page</option>
8518            <option value="100">100 per page</option>
8519          </select>
8520          <span class="per-page-label" id="page-range-label"></span>
8521        </div>
8522      </div>
8523      {% endif %}
8524    </section>
8525  </div>
8526
8527  <footer class="site-footer">
8528    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
8529    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8530    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8531    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8532  </footer>
8533
8534  <script nonce="{{ csp_nonce }}">
8535    (function () {
8536      // ── Theme ──────────────────────────────────────────────────────────────
8537      var storageKey = 'oxide-sloc-theme';
8538      var body = document.body;
8539      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8540      var toggle = document.getElementById('theme-toggle');
8541      if (toggle) toggle.addEventListener('click', function () {
8542        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8543        body.classList.toggle('dark-theme', next === 'dark');
8544        try { localStorage.setItem(storageKey, next); } catch(e) {}
8545      });
8546
8547      // ── State ─────────────────────────────────────────────────────────────
8548      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
8549      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
8550      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
8551
8552      // ── Stat chips ────────────────────────────────────────────────────────
8553      (function() {
8554        var projects = {}, latestTs = '', latestRow = null;
8555        allRows.forEach(function(r) {
8556          var p = r.dataset.project || ''; if (p) projects[p] = true;
8557          var ts = r.dataset.timestamp || '';
8558          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
8559        });
8560        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
8561        if (latestRow) {
8562          var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
8563          var fe = document.getElementById('agg-files'); if (fe) fe.textContent = latestRow.dataset.files;
8564        }
8565      })();
8566
8567      // ── Branch filter population ──────────────────────────────────────────
8568      (function() {
8569        var branches = {};
8570        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
8571        var sel = document.getElementById('branch-filter');
8572        if (sel) Object.keys(branches).sort().forEach(function(b) {
8573          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
8574        });
8575      })();
8576
8577      // ── Filter ────────────────────────────────────────────────────────────
8578      function getFilteredRows() {
8579        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
8580        var branch = ((document.getElementById('branch-filter') || {}).value || '');
8581        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
8582          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
8583          if (branch && (r.dataset.branch || '') !== branch) return false;
8584          return true;
8585        });
8586      }
8587
8588      // ── Pagination ────────────────────────────────────────────────────────
8589      function renderPage() {
8590        var filtered = getFilteredRows();
8591        var total = filtered.length;
8592        var totalPages = Math.max(1, Math.ceil(total / perPage));
8593        currentPage = Math.min(currentPage, totalPages);
8594        var start = (currentPage - 1) * perPage;
8595        var end = Math.min(start + perPage, total);
8596        var shown = {};
8597        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
8598        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
8599          r.style.display = shown[r.dataset.run] ? '' : 'none';
8600        });
8601        var rl = document.getElementById('page-range-label');
8602        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8603        var info = document.getElementById('pagination-info');
8604        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
8605        var btns = document.getElementById('pagination-btns');
8606        if (!btns) return;
8607        btns.innerHTML = '';
8608        function makeBtn(lbl, pg, active, disabled) {
8609          var b = document.createElement('button');
8610          b.className = 'pg-btn' + (active ? ' active' : '');
8611          b.textContent = lbl; b.disabled = disabled;
8612          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
8613          return b;
8614        }
8615        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
8616        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8617        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
8618        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
8619      }
8620
8621      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
8622      window.applyFilters = function() { currentPage = 1; renderPage(); };
8623
8624      // ── Sorting ───────────────────────────────────────────────────────────
8625      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
8626      function doSort(col, type, order) {
8627        var tbody = document.getElementById('compare-tbody');
8628        if (!tbody) return;
8629        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8630        rows.sort(function(a, b) {
8631          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
8632          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
8633          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8634          return va < vb ? 1 : va > vb ? -1 : 0;
8635        });
8636        rows.forEach(function(r) { tbody.appendChild(r); });
8637        currentPage = 1; renderPage();
8638      }
8639      sortHeaders.forEach(function(th) {
8640        th.addEventListener('click', function(e) {
8641          if (e.target.classList.contains('col-resize-handle')) return;
8642          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8643          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8644          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8645          th.classList.add('sort-' + sortOrder);
8646          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8647          doSort(col, type, sortOrder);
8648        });
8649      });
8650
8651      // ── Column resize ─────────────────────────────────────────────────────
8652      (function() {
8653        var table = document.getElementById('compare-table');
8654        if (!table) return;
8655        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8656        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
8657        ths.forEach(function(th, i) {
8658          var handle = th.querySelector('.col-resize-handle');
8659          if (!handle || !cols[i]) return;
8660          var startX, startW;
8661          handle.addEventListener('mousedown', function(e) {
8662            e.stopPropagation(); e.preventDefault();
8663            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8664            handle.classList.add('dragging');
8665            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8666            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8667            document.addEventListener('mousemove', onMove);
8668            document.addEventListener('mouseup', onUp);
8669          });
8670        });
8671      })();
8672
8673      // ── Reset view ────────────────────────────────────────────────────────
8674      window.resetView = function() {
8675        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
8676        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
8677        sortCol = null; sortOrder = 'asc';
8678        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8679        var tbody = document.getElementById('compare-tbody');
8680        if (tbody) {
8681          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8682          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8683          rows.forEach(function(r) { tbody.appendChild(r); });
8684        }
8685        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
8686        var table = document.getElementById('compare-table');
8687        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8688        currentPage = 1; renderPage();
8689      };
8690
8691      renderPage();
8692
8693      (function randomizeWatermarks() {
8694        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8695        if (!wms.length) return;
8696        var placed = [];
8697        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;}
8698        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];}
8699        var half=Math.floor(wms.length/2);
8700        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.cssText='width:'+sz+'px;top:'+pos[0].toFixed(1)+'%;left:'+pos[1].toFixed(1)+'%;transform:rotate('+rot+'deg);opacity:'+op+';';});
8701      })();
8702
8703      (function spawnCodeParticles() {
8704        var container = document.getElementById('code-particles');
8705        if (!container) return;
8706        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'];
8707        for (var i = 0; i < 38; i++) {
8708          (function(idx) {
8709            var el = document.createElement('span');
8710            el.className = 'code-particle';
8711            el.textContent = snippets[idx % snippets.length];
8712            var left = Math.random() * 94 + 2;
8713            var top = Math.random() * 88 + 6;
8714            var dur = (Math.random() * 10 + 9).toFixed(1);
8715            var delay = (Math.random() * 18).toFixed(1);
8716            var rot = (Math.random() * 26 - 13).toFixed(1);
8717            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8718            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8719            container.appendChild(el);
8720          })(i);
8721        }
8722      })();
8723    })();
8724
8725    var selected = [];
8726    function updateCompareBtn() {
8727      var btn = document.getElementById('compare-btn');
8728      var cnt = document.getElementById('sel-count');
8729      if (!btn) return;
8730      btn.disabled = selected.length !== 2;
8731      if (cnt) cnt.textContent = selected.length + '/2';
8732    }
8733
8734    function toggleRow(row, runId) {
8735      var idx = selected.indexOf(runId);
8736      if (idx >= 0) {
8737        selected.splice(idx, 1);
8738        row.classList.remove('selected');
8739        var b = document.getElementById('badge-' + runId);
8740        if (b) b.textContent = '';
8741      } else {
8742        if (selected.length >= 2) return;
8743        selected.push(runId);
8744        row.classList.add('selected');
8745        var b = document.getElementById('badge-' + runId);
8746        if (b) b.textContent = selected.length;
8747      }
8748      selected.forEach(function(id, i) {
8749        var b = document.getElementById('badge-' + id);
8750        if (b) b.textContent = i + 1;
8751      });
8752      updateCompareBtn();
8753    }
8754
8755    function doCompare() {
8756      if (selected.length !== 2) return;
8757      window.location.href = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
8758    }
8759  </script>
8760</body>
8761</html>
8762"##,
8763    ext = "html"
8764)]
8765struct CompareSelectTemplate {
8766    entries: Vec<HistoryEntryRow>,
8767    total_scans: usize,
8768    csp_nonce: String,
8769}
8770
8771// ── CompareTemplate ────────────────────────────────────────────────────────────
8772
8773#[derive(Template)]
8774#[template(
8775    source = r##"
8776<!doctype html>
8777<html lang="en">
8778<head>
8779  <meta charset="utf-8">
8780  <meta name="viewport" content="width=device-width, initial-scale=1">
8781  <title>OxideSLOC | Scan Delta</title>
8782  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8783  <style nonce="{{ csp_nonce }}">
8784    :root {
8785      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
8786      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
8787      --nav:#b85d33; --nav-2:#7a371b;
8788      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
8789      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea; --zero-bg:transparent;
8790      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
8791    }
8792    body.dark-theme {
8793      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
8794      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#f5a3a3; --neg-bg:#3d1c1c;
8795    }
8796    *{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);}
8797    .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);}
8798    .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;}
8799    .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));}
8800    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8801    .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;}
8802    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
8803    .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;}
8804    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
8805    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8806    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8807    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8808    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
8809    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
8810    .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;}
8811    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
8812    .hero-body{display:block;}
8813    .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;}
8814    .btn-back:hover{background:var(--line);}
8815    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;}
8816    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
8817    .muted{color:var(--muted);font-size:14px;}
8818    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
8819    .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;}
8820    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
8821    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
8822    .vpill-arrow{font-size:20px;color:var(--muted);}
8823    .delta-strip{display:grid;grid-template-columns:minmax(130px,1fr) minmax(130px,1fr) minmax(110px,0.75fr) minmax(110px,0.75fr) minmax(110px,0.75fr) minmax(180px,1.4fr);gap:12px;width:100%;}
8824    .delta-card{background:var(--surface-2);border:1px solid var(--line);border-radius:14px;padding:14px 16px;display:flex;flex-direction:column;justify-content:center;min-height:116px;position:relative;cursor:default;}
8825    .delta-card.delta-card-wide{padding:14px 18px;}
8826    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);}
8827    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
8828    .delta-card-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:4px;}
8829    .delta-card-from{font-size:12px;color:var(--muted);}
8830    .delta-card-to{font-size:20px;font-weight:800;margin:2px 0;}
8831    .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;}
8832    .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);}
8833    .delta-card:hover .dc-tip{display:block;}
8834    .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;}
8835    .export-btn:hover{background:var(--line);}
8836    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
8837    .delta-card-change{font-size:13px;font-weight:700;border-radius:6px;padding:1px 7px;display:inline-block;margin-top:2px;}
8838    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
8839    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
8840    .delta-card-change.zero{color:var(--muted);background:transparent;}
8841    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
8842    .fc-row{display:flex;align-items:center;gap:8px;}
8843    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
8844    .fc-label{color:var(--muted);}
8845    .fc-modified .fc-count{color:#926000;}
8846    .fc-added .fc-count{color:var(--pos);}
8847    .fc-removed .fc-count{color:var(--neg);}
8848    .fc-unchanged .fc-count{color:var(--muted);}
8849    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
8850    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
8851    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
8852    .chip.modified{background:#fff2d8;color:#926000;}
8853    .chip.added{background:#e8f5ed;color:#1a8f47;}
8854    .chip.removed{background:#fdeaea;color:#b33b3b;}
8855    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
8856    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
8857    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
8858    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
8859    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
8860    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
8861    .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;}
8862    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
8863    .tab-btn:hover:not(.active){background:var(--line);}
8864    .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;}
8865    .btn-reset:hover{background:var(--line);}
8866    .table-wrap{width:100%;overflow-x:auto;}
8867    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
8868    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;}
8869    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
8870    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
8871    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
8872    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
8873    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
8874    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8875    tr:last-child td{border-bottom:none;}
8876    tr.row-added td{background:rgba(26,143,71,0.06);}
8877    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
8878    tr.row-modified td{background:rgba(146,96,0,0.05);}
8879    tr.row-unchanged td{opacity:.6;}
8880    .file-path{font-family:ui-monospace,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8881    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
8882    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
8883    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
8884    .status-badge.modified{background:#fff2d8;color:#926000;}
8885    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
8886    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
8887    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
8888    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
8889    .delta-val{font-weight:700;}
8890    .delta-val.pos{color:var(--pos);}
8891    .delta-val.neg{color:var(--neg);}
8892    .delta-val.zero{color:var(--muted);}
8893    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
8894    .from-to strong{color:var(--text);}
8895    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8896    .site-footer a{color:var(--muted);}
8897    @media(max-width:1400px){.delta-strip{grid-template-columns:repeat(3,1fr);}}
8898    @media(max-width:900px){.delta-strip{grid-template-columns:repeat(2,1fr);}}
8899    @media(max-width:600px){.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
8900    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8901    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8902    .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;}
8903    .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;}
8904    .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;}
8905    @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));}}
8906    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
8907    .path-link:hover{color:var(--oxide-2);}
8908    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
8909    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
8910    a.vpill-id:hover{color:var(--oxide);}
8911    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
8912    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
8913    .pagination-info{font-size:13px;color:var(--muted);}
8914    .pagination-btns{display:flex;gap:6px;}
8915    .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;}
8916    .pg-btn:hover:not(:disabled){background:var(--line);}
8917    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8918    .pg-btn:disabled{opacity:.35;cursor:default;}
8919    .per-page-label{font-size:13px;color:var(--muted);}
8920    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;}
8921    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8922    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
8923    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
8924    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
8925    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
8926    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
8927    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
8928    .tab-btn.tab-unchanged{color:var(--muted);}
8929    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
8930    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
8931    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
8932  </style>
8933</head>
8934<body>
8935  <div class="background-watermarks" aria-hidden="true">
8936    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8937    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8938    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8939    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8940    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8941    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8942  </div>
8943  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8944  <div class="top-nav">
8945    <div class="top-nav-inner">
8946      <a class="brand" href="/">
8947        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8948        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
8949      </a>
8950      <div class="nav-right">
8951        <a class="nav-pill" href="/">Home</a>
8952        <a class="nav-pill" href="/view-reports">View Reports</a>
8953        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8954        <div class="server-status-wrap">
8955          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8956          <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>
8957        </div>
8958        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8959          <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>
8960          <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>
8961        </button>
8962      </div>
8963    </div>
8964  </div>
8965
8966  <div class="page">
8967    <section class="hero">
8968      <div class="hero-header">
8969        <div>
8970          <h1 style="margin:0 0 6px;">Scan Delta</h1>
8971          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
8972            <span class="muted" style="font-size:13px;">Comparing two scans of</span>
8973            <a class="path-link" data-folder="{{ project_path }}" href="#" onclick="fetch('/open-path?path='+encodeURIComponent(this.dataset.folder));return false;" style="font-size:13px;font-weight:700;">{{ project_path }}</a>
8974          </div>
8975          <div style="display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap;">
8976            <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:4px 10px;color:var(--muted);">
8977              <span style="color:var(--text);font-weight:700;">Baseline</span>&nbsp;&nbsp;{{ baseline_timestamp }}
8978            </span>
8979            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--muted);flex:0 0 auto;"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
8980            <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--oxide);border-radius:8px;padding:4px 10px;color:var(--muted);">
8981              <span style="color:var(--oxide);font-weight:700;">Current</span>&nbsp;&nbsp;{{ current_timestamp }}
8982            </span>
8983          </div>
8984        </div>
8985        <a class="btn-back" href="/compare-scans">
8986          <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>
8987          Compare Scans
8988        </a>
8989      </div>
8990      <div class="hero-body">
8991      <div class="delta-strip">
8992        <div class="delta-card delta-card-meta">
8993          <div class="delta-card-label">Baseline</div>
8994          <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ baseline_timestamp }}</div>
8995          <a class="vpill-id" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_run_id_short }}</a>
8996          {% if !baseline_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ baseline_git_branch }}</span>{% endif %}
8997          {% if let Some(author) = baseline_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8998          {% if let Some(tags) = baseline_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8999        </div>
9000        <div class="delta-card delta-card-meta">
9001          <div class="delta-card-label">Current</div>
9002          <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ current_timestamp }}</div>
9003          <a class="vpill-id" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_run_id_short }}</a>
9004          {% if !current_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ current_git_branch }}</span>{% endif %}
9005          {% if let Some(author) = current_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
9006          {% if let Some(tags) = current_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
9007        </div>
9008        <div class="delta-card">
9009          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
9010          <div class="delta-card-label">Code lines</div>
9011          <div class="delta-card-from">Before: {{ baseline_code }}</div>
9012          <div class="delta-card-to">{{ current_code }}</div>
9013          {% if code_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ code_lines_delta_str }}</span>
9014          {% else if code_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ code_lines_delta_str }}</span>
9015          {% endif %}
9016        </div>
9017        <div class="delta-card">
9018          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
9019          <div class="delta-card-label">Files analyzed</div>
9020          <div class="delta-card-from">Before: {{ baseline_files }}</div>
9021          <div class="delta-card-to">{{ current_files }}</div>
9022          {% if files_analyzed_delta_class == "pos" %}<span class="delta-card-change pos">{{ files_analyzed_delta_str }}</span>
9023          {% else if files_analyzed_delta_class == "neg" %}<span class="delta-card-change neg">{{ files_analyzed_delta_str }}</span>
9024          {% endif %}
9025        </div>
9026        <div class="delta-card">
9027          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
9028          <div class="delta-card-label">Comment lines</div>
9029          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
9030          <div class="delta-card-to">{{ current_comments }}</div>
9031          {% if comment_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ comment_lines_delta_str }}</span>
9032          {% else if comment_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ comment_lines_delta_str }}</span>
9033          {% endif %}
9034        </div>
9035        <div class="delta-card delta-card-wide">
9036          <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>
9037          <div class="delta-card-label">File changes</div>
9038          <div class="file-changes-grid">
9039            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
9040            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
9041            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
9042            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
9043          </div>
9044        </div>
9045      </div>
9046      </div>
9047    </section>
9048
9049    <section class="panel">
9050      <h2>File-level delta</h2>
9051      <div class="filter-tabs-row">
9052        <div class="filter-tabs">
9053          <button class="tab-btn tab-all active" onclick="filterRows('all', this)">All</button>
9054          <button class="tab-btn tab-modified" onclick="filterRows('modified', this)">Modified ({{ files_modified }})</button>
9055          <button class="tab-btn tab-added" onclick="filterRows('added', this)">Added ({{ files_added }})</button>
9056          <button class="tab-btn tab-removed" onclick="filterRows('removed', this)">Removed ({{ files_removed }})</button>
9057          <button class="tab-btn tab-unchanged" onclick="filterRows('unchanged', this)">Unchanged ({{ files_unchanged }})</button>
9058        </div>
9059        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
9060          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
9061          <div class="export-group">
9062            <button type="button" class="btn-reset" onclick="resetDeltaTable()">&#8635; Reset</button>
9063            <button type="button" class="export-btn" onclick="exportDeltaCsv()">
9064              <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>
9065              CSV
9066            </button>
9067            <button type="button" class="export-btn" onclick="exportDeltaXls()">
9068              <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>
9069              Excel
9070            </button>
9071            <button type="button" class="export-btn" onclick="exportDeltaCharts()">
9072              <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>
9073              Charts
9074            </button>
9075          </div>
9076        </div>
9077      </div>
9078
9079      <div class="table-wrap">
9080      <table id="delta-table">
9081        <colgroup>
9082          <col style="width:34%">
9083          <col style="width:10%">
9084          <col style="width:9%">
9085          <col style="width:15%">
9086          <col style="width:8%">
9087          <col style="width:8%">
9088          <col style="width:8%">
9089        </colgroup>
9090        <thead>
9091          <tr id="delta-thead">
9092            <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>
9093            <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>
9094            <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>
9095            <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>
9096            <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>
9097            <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>
9098            <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>
9099          </tr>
9100        </thead>
9101        <tbody id="delta-tbody">
9102          {% for row in file_rows %}
9103          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
9104              data-path="{{ row.relative_path }}"
9105              data-language="{{ row.language }}"
9106              data-baseline-code="{{ row.baseline_code }}"
9107              data-current-code="{{ row.current_code }}"
9108              data-code-delta="{{ row.code_delta_str }}"
9109              data-comment-delta="{{ row.comment_delta_str }}"
9110              data-total-delta="{{ row.total_delta_str }}"
9111              data-orig-idx="">
9112            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
9113            <td class="hide-sm">{{ row.language }}</td>
9114            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
9115            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
9116            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
9117            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
9118            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
9119          </tr>
9120          {% endfor %}
9121        </tbody>
9122      </table>
9123      </div>
9124      <div class="pagination">
9125        <span class="pagination-info" id="pg-info"></span>
9126        <div class="pagination-btns" id="pg-btns"></div>
9127        <div style="display:flex;align-items:center;gap:8px;">
9128          <span class="per-page-label">Show</span>
9129          <select class="per-page" id="per-page-sel" onchange="setDeltaPerPage(this.value)">
9130            <option value="10">10 per page</option>
9131            <option value="25" selected>25 per page</option>
9132            <option value="50">50 per page</option>
9133            <option value="100">100 per page</option>
9134          </select>
9135          <span class="per-page-label" id="pg-range-label"></span>
9136        </div>
9137      </div>
9138    </section>
9139  </div>
9140
9141  <footer class="site-footer">
9142    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
9143    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9144    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9145  </footer>
9146
9147  <script nonce="{{ csp_nonce }}">
9148    (function () {
9149      var storageKey = 'oxide-sloc-theme';
9150      var body = document.body;
9151      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
9152      var toggle = document.getElementById('theme-toggle');
9153      if (toggle) toggle.addEventListener('click', function () {
9154        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
9155        body.classList.toggle('dark-theme', next === 'dark');
9156        try { localStorage.setItem(storageKey, next); } catch(e) {}
9157      });
9158
9159      (function randomizeWatermarks() {
9160        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9161        if (!wms.length) return;
9162        var placed = [];
9163        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;}
9164        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];}
9165        var half=Math.floor(wms.length/2);
9166        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.cssText='width:'+sz+'px;top:'+pos[0].toFixed(1)+'%;left:'+pos[1].toFixed(1)+'%;transform:rotate('+rot+'deg);opacity:'+op+';';});
9167      })();
9168
9169      (function spawnCodeParticles() {
9170        var container = document.getElementById('code-particles');
9171        if (!container) return;
9172        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'];
9173        for (var i = 0; i < 38; i++) {
9174          (function(idx) {
9175            var el = document.createElement('span');
9176            el.className = 'code-particle';
9177            el.textContent = snippets[idx % snippets.length];
9178            var left = Math.random() * 94 + 2;
9179            var top = Math.random() * 88 + 6;
9180            var dur = (Math.random() * 10 + 9).toFixed(1);
9181            var delay = (Math.random() * 18).toFixed(1);
9182            var rot = (Math.random() * 26 - 13).toFixed(1);
9183            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9184            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
9185            container.appendChild(el);
9186          })(i);
9187        }
9188      })();
9189    })();
9190
9191    var activeStatusFilter = 'all';
9192    var deltaPerPage = 25, deltaCurrPage = 1;
9193
9194    function openFolder(path) {
9195      fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
9196    }
9197
9198    function getDeltaFilteredRows() {
9199      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
9200        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
9201      });
9202    }
9203
9204    function renderDeltaPage() {
9205      var filtered = getDeltaFilteredRows();
9206      var total = filtered.length;
9207      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
9208      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
9209      var start = (deltaCurrPage - 1) * deltaPerPage;
9210      var end = Math.min(start + deltaPerPage, total);
9211      var shownSet = {};
9212      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
9213      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
9214        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
9215      });
9216      var rl = document.getElementById('pg-range-label');
9217      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
9218      var info = document.getElementById('pg-info');
9219      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
9220      var btns = document.getElementById('pg-btns');
9221      if (!btns) return;
9222      btns.innerHTML = '';
9223      if (totalPages <= 1) return;
9224      function makeBtn(lbl, pg, active, disabled) {
9225        var b = document.createElement('button');
9226        b.className = 'pg-btn' + (active ? ' active' : '');
9227        b.textContent = lbl; b.disabled = disabled;
9228        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
9229        return b;
9230      }
9231      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
9232      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
9233      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
9234      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
9235    }
9236
9237    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
9238
9239    function filterRows(status, btn) {
9240      activeStatusFilter = status;
9241      deltaCurrPage = 1;
9242      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
9243        b.classList.remove('active');
9244      });
9245      if (btn) btn.classList.add('active');
9246      renderDeltaPage();
9247    }
9248
9249    // ── Sorting ──────────────────────────────────────────────────────────────
9250    var sortCol = null, sortOrder = 'asc';
9251    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
9252    (function() {
9253      var tbody = document.getElementById('delta-tbody');
9254      if (!tbody) return;
9255      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
9256      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
9257    })();
9258
9259    function parseDeltaNum(str) {
9260      if (!str || str === '—') return 0;
9261      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
9262    }
9263
9264    sortHeaders.forEach(function(th) {
9265      th.addEventListener('click', function(e) {
9266        if (e.target.classList.contains('col-resize-handle')) return;
9267        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
9268        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
9269        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
9270        th.classList.add('sort-' + sortOrder);
9271        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
9272        var tbody = document.getElementById('delta-tbody');
9273        if (!tbody) return;
9274        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
9275        rows.sort(function(a, b) {
9276          var va, vb;
9277          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
9278          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
9279          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
9280          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
9281          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
9282          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
9283          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
9284          else { va = ''; vb = ''; }
9285          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
9286          return va < vb ? 1 : va > vb ? -1 : 0;
9287        });
9288        rows.forEach(function(r) { tbody.appendChild(r); });
9289        deltaCurrPage = 1;
9290        renderDeltaPage();
9291        var activeBtn = document.querySelector('.tab-btn.active');
9292        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
9293        if (activeBtn) activeBtn.classList.add('active');
9294      });
9295    });
9296
9297    // ── Column resize ─────────────────────────────────────────────────────────
9298    (function() {
9299      var table = document.getElementById('delta-table');
9300      if (!table) return;
9301      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
9302      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
9303      ths.forEach(function(th, i) {
9304        var handle = th.querySelector('.col-resize-handle');
9305        if (!handle || !cols[i]) return;
9306        var startX, startW;
9307        handle.addEventListener('mousedown', function(e) {
9308          e.stopPropagation(); e.preventDefault();
9309          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
9310          handle.classList.add('dragging');
9311          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
9312          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
9313          document.addEventListener('mousemove', onMove);
9314          document.addEventListener('mouseup', onUp);
9315        });
9316      });
9317    })();
9318
9319    // ── Reset ─────────────────────────────────────────────────────────────────
9320    window.resetDeltaTable = function() {
9321      sortCol = null; sortOrder = 'asc';
9322      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
9323      var tbody = document.getElementById('delta-tbody');
9324      if (tbody) {
9325        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
9326        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
9327        rows.forEach(function(r) { tbody.appendChild(r); });
9328      }
9329      var table = document.getElementById('delta-table');
9330      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
9331      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
9332      activeStatusFilter = 'all';
9333      deltaCurrPage = 1;
9334      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
9335      var allBtn = document.querySelector('.tab-btn');
9336      if (allBtn) allBtn.classList.add('active');
9337      renderDeltaPage();
9338    };
9339
9340    renderDeltaPage();
9341
9342    // ── Export helpers ────────────────────────────────────────────────────────
9343    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
9344    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
9345    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);}
9346    function slocMakeXlsx(fname,sd,dr){
9347      var enc=new TextEncoder();
9348      // CRC-32 table
9349      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;}
9350      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;}
9351      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
9352      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
9353      // Shared string table
9354      var ss=[],si={};
9355      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
9356      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
9357      // Worksheet builder — each WS() call gets its own row counter R
9358      function WS(){
9359        var R=0,buf=[];
9360        function cl(c){return String.fromCharCode(65+c);}
9361        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
9362          '<v>'+S(v)+'</v></c>';}
9363        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
9364          (st?' s="'+st+'"':'')+'>'+
9365          '<v>'+(+v)+'</v></c>';}
9366        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
9367        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
9368          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
9369          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
9370          '<sheetFormatPr defaultRowHeight="15"/>'+
9371          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
9372        return{sc:sc,nc:nc,row:row,xml:xml};
9373      }
9374      // Language breakdown
9375      var lm={};
9376      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;});
9377      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
9378      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
9379      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
9380      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
9381      // Summary sheet
9382      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
9383      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
9384      r1(s1(0,proj,2));
9385      r1(s1(0,sd.bts+' → '+sd.cts,2));
9386      r1('');
9387      r1(s1(0,'Metric',3)+s1(1,'Baseline',3)+s1(2,'Current',3)+s1(3,'Delta',3));
9388      r1(s1(0,'Code Lines')+n1(1,sd.bc,4)+n1(2,sd.cc,4)+s1(3,sd.cd,dstyle(sd.cd)));
9389      r1(s1(0,'Files Analyzed')+n1(1,sd.bf,4)+n1(2,sd.cf,4)+s1(3,sd.fd,dstyle(sd.fd)));
9390      r1(s1(0,'Comment Lines')+n1(1,sd.bcm,4)+n1(2,sd.ccm,4)+s1(3,sd.cmd,dstyle(sd.cmd)));
9391      r1('');
9392      r1(s1(0,'FILE CHANGES',8));
9393      r1(s1(0,'Category',3)+s1(3,'Count',3));
9394      r1(s1(0,'Modified')+n1(3,sd.fm,4));
9395      r1(s1(0,'Added')+n1(3,sd.fa,4));
9396      r1(s1(0,'Removed')+n1(3,sd.fr,4));
9397      r1(s1(0,'Unchanged')+n1(3,sd.fu,4));
9398      if(langs.length){
9399        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
9400        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
9401        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)));});
9402      }
9403      r1('');r1(s1(0,'SCAN METADATA',8));
9404      r1(s1(1,'Baseline')+s1(2,'Current'));
9405      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
9406      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
9407      var sh1=W1.xml('<col min="1" max="1" width="24" customWidth="1"/><col min="2" max="4" width="14" customWidth="1"/>');
9408      // File Delta sheet
9409      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
9410      r2(s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3)+s2(3,'Code Before',3)+s2(4,'Code After',3)+s2(5,'Code Delta',3)+s2(6,'Comment Delta',3)+s2(7,'Total Delta',3));
9411      dr.forEach(function(r){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])));});
9412      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="8" width="13" customWidth="1"/>');
9413      // Shared strings XML
9414      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
9415        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
9416        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
9417      // XLSX file map
9418      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
9419      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>',
9420        '_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>',
9421        '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>',
9422        '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>',
9423        '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"/><xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="3" fillId="2" borderId="0" xfId="0" applyFill="1"><alignment horizontal="left"/></xf><xf numFmtId="3" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="4" fillId="3" borderId="0" xfId="0" applyFill="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="5" fillId="4" borderId="0" xfId="0" applyFill="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="6" fillId="0" borderId="0" xfId="0"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="7" fillId="0" borderId="0" xfId="0"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>',
9424        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
9425      // ZIP packer — STORED (no compression), compatible with all XLSX readers
9426      var zparts=[],zcds=[],zoff=0,znf=0;
9427      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
9428       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
9429      ].forEach(function(name){
9430        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
9431        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]);
9432        var entry=new Uint8Array(lha.length+nb.length+sz);
9433        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
9434        zparts.push(entry);
9435        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));
9436        var cde=new Uint8Array(cda.length+nb.length);
9437        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
9438        zcds.push(cde);zoff+=entry.length;znf++;
9439      });
9440      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
9441      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]);
9442      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
9443      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
9444      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
9445      zout.set(new Uint8Array(ea),zpos);
9446      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
9447      var xurl=URL.createObjectURL(xblob);
9448      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
9449      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
9450      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
9451    }
9452    function slocCsvMulti(fname,sections){var parts=[];sections.forEach(function(sec,idx){if(idx>0){parts.push('');parts.push('');}parts.push(sec.hdrs.map(slocEscCsv).join(','));sec.rows.forEach(function(r){parts.push(r.map(slocEscCsv).join(','));});});slocDownload(parts.join('\r\n'),fname,'text/csv;charset=utf-8;');}
9453    function getExportFilename(ext){var el=document.querySelector('[data-folder]');var path=el?el.getAttribute('data-folder'):'project';var slug=(path.replace(/\\/g,'/').split('/').filter(Boolean).pop()||'project').replace(/[^a-zA-Z0-9_-]/g,'-').toLowerCase();return slug+'_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}.'+ext;}
9454
9455    var _summaryHdrs = ['Metric','Baseline','Current','Delta'];
9456    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 }}'};
9457    function getSummaryExportRows(){return[['Code Lines',String(_sd.bc),String(_sd.cc),_sd.cd],['Files Analyzed',String(_sd.bf),String(_sd.cf),_sd.fd],['Comment Lines',String(_sd.bcm),String(_sd.ccm),_sd.cmd],['Modified Files','','',String(_sd.fm)],['Added Files','','',String(_sd.fa)],['Removed Files','','',String(_sd.fr)],['Unchanged Files','','',String(_sd.fu)]];}
9458    var _dh = ['File','Language','Status','Code Before','Code After','Code Delta','Comment Delta','Total Delta'];
9459    function getDeltaExportRows(){var r=[];document.querySelectorAll('#delta-tbody .delta-row').forEach(function(tr){r.push([tr.getAttribute('data-path')||'',tr.getAttribute('data-language')||'',tr.getAttribute('data-status')||'',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')||'']);});return r;}
9460    window.exportDeltaCsv = function(){slocCsvMulti(getExportFilename('csv'),[{hdrs:_summaryHdrs,rows:getSummaryExportRows()},{hdrs:_dh,rows:getDeltaExportRows()}]);};
9461    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
9462
9463    // ── Chart HTML report ─────────────────────────────────────────────────────
9464    function slocChartReport(fname, sd, dr) {
9465      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
9466      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
9467      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
9468      function fmt(n){return Number(n).toLocaleString();}
9469      function px(n){return Math.round(n);}
9470      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
9471      // Language map
9472      var lm={};
9473      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;});
9474      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
9475
9476      // Builds onmouse* attrs for interactive tooltip on each SVG element
9477      function barTT(label,val){
9478        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
9479      }
9480
9481      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
9482      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}];
9483      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
9484      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
9485      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
9486      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9487      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"/>';}
9488      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
9489      c1mets.forEach(function(m,i){
9490        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
9491        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
9492        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>';
9493        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))+'/>';
9494        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>';
9495        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))+'/>';
9496        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>';
9497        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>';
9498        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>';
9499      });
9500      c1+='</svg>';
9501
9502      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
9503      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}];
9504      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
9505      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
9506      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
9507      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9508      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9509      mets.forEach(function(m,i){
9510        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
9511        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
9512        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
9513        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>';
9514        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
9515        if(bw>=52){
9516          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>';
9517        }else{
9518          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
9519          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>';
9520        }
9521      });
9522      c2+='</svg>';
9523
9524      // ── Chart 3: Language Code Delta ─────────────────────────────────────
9525      var c3='';
9526      if(langs.length){
9527        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
9528        var C3W=550,c3LW=124,c3FW=52;
9529        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
9530        var L3rH=30,C3H=langs.length*L3rH+20;
9531        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9532        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9533        langs.forEach(function(l,i){
9534          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
9535          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
9536          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
9537          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9538          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':''))+'/>';
9539          if(bw>=48){
9540            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>';
9541          }else{
9542            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
9543            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>';
9544          }
9545          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>';
9546        });
9547        c3+='</svg>';
9548      }
9549
9550      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
9551      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;});
9552      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
9553      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
9554      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9555      var ang=-Math.PI/2;
9556      segs.forEach(function(s){
9557        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
9558        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
9559        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
9560        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
9561        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
9562        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)+'%')+'/>';
9563        ang+=sw;
9564      });
9565      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>';
9566      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9567      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>';});
9568      c4+='</svg>';
9569
9570      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
9571      var ttJs='var tt=document.getElementById("ox-tt");'+
9572        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
9573        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
9574        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
9575        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
9576        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
9577        'function oxHT(){tt.style.display="none";}';
9578
9579      // body max-width keeps charts from inflating beyond design dimensions on
9580      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
9581      // each chart's height blows up proportionally, breaking the one-page layout.
9582      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;}'+
9583        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
9584        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
9585        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
9586        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
9587        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
9588        'svg{display:block;}'+
9589        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
9590        '#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;}'+
9591        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
9592      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
9593        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
9594        '<div id="ox-tt"><\/div>'+
9595        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
9596        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
9597        '<div class="two-col">'+
9598        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
9599        '<div class="leg"><span><span class="dot" style="background:#AAAAAA"><\/span>Baseline<\/span>'+
9600        '<span><span class="dot" style="background:#C45C10"><\/span>Current<\/span><\/div>'+c1+'<\/div>'+
9601        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
9602        '<\/div>'+
9603        '<div class="two-col">'+
9604        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
9605        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
9606        '<\/div>'+
9607        '<script>'+ttJs+'<\/script>'+
9608        '<\/body><\/html>';
9609      slocDownload(html, fname, 'text/html;charset=utf-8;');
9610    }
9611    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
9612  </script>
9613</body>
9614</html>
9615"##,
9616    ext = "html"
9617)]
9618struct CompareTemplate {
9619    baseline_run_id: String,
9620    current_run_id: String,
9621    baseline_run_id_short: String,
9622    current_run_id_short: String,
9623    baseline_timestamp: String,
9624    current_timestamp: String,
9625    project_path: String,
9626    baseline_code: u64,
9627    current_code: u64,
9628    code_lines_delta_str: String,
9629    code_lines_delta_class: String,
9630    baseline_files: u64,
9631    current_files: u64,
9632    files_analyzed_delta_str: String,
9633    files_analyzed_delta_class: String,
9634    baseline_comments: u64,
9635    current_comments: u64,
9636    comment_lines_delta_str: String,
9637    comment_lines_delta_class: String,
9638    files_added: usize,
9639    files_removed: usize,
9640    files_modified: usize,
9641    files_unchanged: usize,
9642    file_rows: Vec<CompareFileDeltaRow>,
9643    baseline_git_author: Option<String>,
9644    current_git_author: Option<String>,
9645    baseline_git_branch: String,
9646    current_git_branch: String,
9647    baseline_git_tags: Option<String>,
9648    current_git_tags: Option<String>,
9649    csp_nonce: String,
9650}