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
4use std::{
5    collections::{HashMap, VecDeque},
6    fs,
7    net::{IpAddr, SocketAddr},
8    path::{Path, PathBuf},
9    process::Stdio,
10    sync::Arc,
11    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
12};
13
14use anyhow::{Context, Result};
15use askama::Template;
16use axum::{
17    body::Body,
18    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
19    http::{header, HeaderValue, Request, StatusCode},
20    middleware::{self, Next},
21    response::{Html, IntoResponse, Response},
22    routing::{get, post},
23    Json, Router,
24};
25use serde::{Deserialize, Serialize};
26use tokio::sync::Mutex;
27use tower_http::cors::CorsLayer;
28
29use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
30
31static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
32
33use sloc_core::{
34    analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
35    ScanSummarySnapshot, SummaryTotals,
36};
37use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
38const MAX_CONCURRENT_ANALYSES: usize = 4;
39
40/// Sliding-window rate limiter keyed by client IP.
41/// Uses only std primitives — no external crate required.
42struct IpRateLimiter {
43    window: Duration,
44    max_requests: usize,
45    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
46}
47
48impl IpRateLimiter {
49    fn new(window: Duration, max_requests: usize) -> Self {
50        Self {
51            window,
52            max_requests,
53            state: std::sync::Mutex::new(HashMap::new()),
54        }
55    }
56
57    fn is_allowed(&self, ip: IpAddr) -> bool {
58        let now = Instant::now();
59        let cutoff = now.checked_sub(self.window).unwrap_or(now);
60        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
61        let bucket = state.entry(ip).or_default();
62        while bucket.front().map(|t| *t <= cutoff).unwrap_or(false) {
63            bucket.pop_front();
64        }
65        if bucket.len() >= self.max_requests {
66            return false;
67        }
68        bucket.push_back(now);
69        true
70    }
71}
72
73#[derive(Clone)]
74struct AppState {
75    base_config: AppConfig,
76    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
77    registry: Arc<Mutex<ScanRegistry>>,
78    registry_path: PathBuf,
79    analyze_semaphore: Arc<tokio::sync::Semaphore>,
80    server_mode: bool,
81    tls_enabled: bool,
82    api_key: Option<String>,
83    rate_limiter: Arc<IpRateLimiter>,
84}
85
86type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
87
88#[derive(Clone, Debug)]
89struct RunArtifacts {
90    output_dir: PathBuf,
91    html_path: Option<PathBuf>,
92    pdf_path: Option<PathBuf>,
93    json_path: Option<PathBuf>,
94    report_title: String,
95}
96
97pub async fn serve(config: AppConfig) -> Result<()> {
98    let bind_address = config.web.bind_address.clone();
99    let server_mode = config.web.server_mode;
100    let output_root = resolve_output_root(None).unwrap_or_else(|_| PathBuf::from("out/web"));
101    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
102    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
103        .map(PathBuf::from)
104        .unwrap_or_else(|_| output_root.join("registry.json"));
105    let mut registry = ScanRegistry::load(&registry_path);
106    registry.prune_stale();
107    let _ = registry.save(&registry_path);
108
109    let api_key = std::env::var("SLOC_API_KEY").ok().filter(|k| !k.is_empty());
110    if server_mode && api_key.is_none() {
111        println!(
112            "WARNING: SLOC_API_KEY is not set. All web endpoints are unauthenticated. \
113             Set SLOC_API_KEY to enable bearer-token authentication."
114        );
115    }
116
117    // FIND-012: warn when TLS is not configured in server mode.
118    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
119    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
120    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
121    if server_mode && !tls_enabled {
122        println!(
123            "WARNING: TLS is not configured. Traffic is cleartext. \
124             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
125             or terminate TLS at a reverse proxy (nginx, caddy)."
126        );
127    }
128
129    // FIND-010: 60 req/min per IP across all routes.
130    let rate_limiter = Arc::new(IpRateLimiter::new(Duration::from_secs(60), 60));
131
132    let state = AppState {
133        base_config: config,
134        artifacts: Arc::new(Mutex::new(HashMap::new())),
135        registry: Arc::new(Mutex::new(registry)),
136        registry_path,
137        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
138        server_mode,
139        tls_enabled,
140        api_key,
141        rate_limiter,
142    };
143
144    let protected = Router::new()
145        .route("/", get(splash))
146        .route("/scan-setup", get(scan_setup_handler))
147        .route("/scan", get(index))
148        .route("/analyze", post(analyze_handler))
149        .route("/preview", get(preview_handler))
150        .route("/pick-directory", get(pick_directory_handler))
151        .route("/open-path", get(open_path_handler))
152        .route("/pick-file", get(pick_file_handler))
153        .route("/locate-report", post(locate_report_handler))
154        .route("/view-reports", get(history_handler))
155        .route("/compare-scans", get(compare_select_handler))
156        .route("/compare", get(compare_handler))
157        .route("/images/:folder/:file", get(image_handler))
158        .route("/runs/:run_id/:artifact", get(artifact_handler))
159        .route("/api/metrics/latest", get(api_metrics_latest_handler))
160        .route("/api/metrics/:run_id", get(api_metrics_run_handler))
161        .route("/api/project-history", get(project_history_handler))
162        .route("/embed/summary", get(embed_handler))
163        .route_layer(middleware::from_fn_with_state(
164            state.clone(),
165            require_api_key,
166        ));
167
168    let app = protected
169        .route("/healthz", get(healthz))
170        .route("/badge/:metric", get(badge_handler))
171        .route("/static/chart.js", get(chart_js_handler))
172        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
173        .layer(middleware::from_fn_with_state(
174            state.clone(),
175            add_security_headers,
176        ))
177        .layer(CorsLayer::new())
178        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
179        .with_state(state.clone());
180
181    let listener = tokio::net::TcpListener::bind(&bind_address)
182        .await
183        .with_context(|| format!("failed to bind local web UI on {bind_address}"))?;
184
185    let addr: SocketAddr = bind_address
186        .parse()
187        .unwrap_or_else(|_| listener.local_addr().expect("listener has a local address"));
188
189    if tls_enabled {
190        let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
191        let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
192        let tls_config = build_tls_config(&cert_path, &key_path)
193            .context("failed to load TLS certificate/key")?;
194        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
195
196        let url = format!("https://{addr}/");
197        println!("OxideSLOC server running at {url} (TLS)");
198        println!("Use Ctrl+C to stop.");
199
200        return serve_tls(listener, app, acceptor, server_mode).await;
201    }
202
203    let scheme = "http";
204    let url = format!("{scheme}://{addr}/");
205    if server_mode {
206        println!("OxideSLOC server running at {url}");
207        println!("Use Ctrl+C to stop.");
208    } else {
209        println!("OxideSLOC local web UI running at {url}");
210        println!("Press Ctrl+C to stop the server.");
211        let open_url = url.clone();
212        tokio::task::spawn_blocking(move || {
213            #[cfg(target_os = "windows")]
214            let _ = std::process::Command::new("cmd")
215                .args(["/c", "start", "", &open_url])
216                .stdout(Stdio::null())
217                .stderr(Stdio::null())
218                .spawn();
219            #[cfg(target_os = "macos")]
220            let _ = std::process::Command::new("open")
221                .arg(&open_url)
222                .stdout(Stdio::null())
223                .stderr(Stdio::null())
224                .spawn();
225            #[cfg(target_os = "linux")]
226            let _ = std::process::Command::new("xdg-open")
227                .arg(&open_url)
228                .stdout(Stdio::null())
229                .stderr(Stdio::null())
230                .spawn();
231        });
232    }
233
234    axum::serve(
235        listener,
236        app.into_make_service_with_connect_info::<SocketAddr>(),
237    )
238    .with_graceful_shutdown(async move {
239        if tokio::signal::ctrl_c().await.is_ok() {
240            println!();
241            if server_mode {
242                println!("Shutting down OxideSLOC server...");
243            } else {
244                println!("Shutting down OxideSLOC local web UI...");
245            }
246            println!("Server stopped cleanly.");
247        }
248    })
249    .await
250    .context("web server terminated unexpectedly")
251}
252
253/// Load a rustls ServerConfig from PEM certificate and key files.
254fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
255    use rustls_pemfile::{certs, private_key};
256    use std::io::BufReader;
257
258    let cert_bytes =
259        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
260    let key_bytes =
261        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
262
263    let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
264        .collect::<std::result::Result<_, _>>()
265        .context("failed to parse TLS certificates")?;
266
267    let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
268        .context("failed to parse TLS private key")?
269        .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
270
271    rustls::ServerConfig::builder()
272        .with_no_client_auth()
273        .with_single_cert(cert_chain, key)
274        .context("failed to build TLS server config")
275}
276
277/// Accept loop with TLS termination using tokio-rustls + hyper-util.
278async fn serve_tls(
279    listener: tokio::net::TcpListener,
280    app: Router,
281    acceptor: tokio_rustls::TlsAcceptor,
282    server_mode: bool,
283) -> Result<()> {
284    use hyper_util::rt::{TokioExecutor, TokioIo};
285    use hyper_util::server::conn::auto::Builder as ConnBuilder;
286    use hyper_util::service::TowerToHyperService;
287    use tower::{Service, ServiceExt};
288
289    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
290
291    loop {
292        tokio::select! {
293            biased;
294            _ = tokio::signal::ctrl_c() => {
295                println!();
296                if server_mode {
297                    println!("Shutting down OxideSLOC server...");
298                } else {
299                    println!("Shutting down OxideSLOC local web UI...");
300                }
301                println!("Server stopped cleanly.");
302                return Ok(());
303            }
304            result = listener.accept() => {
305                let (tcp, peer_addr) = result.context("TLS accept failed")?;
306                let acceptor = acceptor.clone();
307                let mut factory = make_svc.clone();
308
309                tokio::spawn(async move {
310                    let tls = match acceptor.accept(tcp).await {
311                        Ok(s) => s,
312                        Err(e) => {
313                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
314                            return;
315                        }
316                    };
317                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
318                        Ok(f) => match Service::call(f, peer_addr).await {
319                            Ok(s) => s,
320                            Err(_) => return,
321                        },
322                        Err(_) => return,
323                    };
324                    let io = TokioIo::new(tls);
325                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
326                        .serve_connection(io, TowerToHyperService::new(svc))
327                        .await
328                    {
329                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
330                    }
331                });
332            }
333        }
334    }
335}
336
337async fn require_api_key(
338    State(state): State<AppState>,
339    req: Request<Body>,
340    next: Next,
341) -> Response {
342    if let Some(ref expected) = state.api_key {
343        let provided = req
344            .headers()
345            .get(header::AUTHORIZATION)
346            .and_then(|v| v.to_str().ok())
347            .and_then(|v| v.strip_prefix("Bearer "))
348            .or_else(|| req.headers().get("X-API-Key").and_then(|v| v.to_str().ok()));
349        if provided.map(|k| ct_eq(k, expected)).unwrap_or(false) {
350            return next.run(req).await;
351        }
352        return (
353            StatusCode::UNAUTHORIZED,
354            [(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
355            "401 Unauthorized\n",
356        )
357            .into_response();
358    }
359    next.run(req).await
360}
361
362fn ct_eq(a: &str, b: &str) -> bool {
363    if a.len() != b.len() {
364        return false;
365    }
366    a.bytes()
367        .zip(b.bytes())
368        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
369        == 0
370}
371
372async fn add_security_headers(
373    State(state): State<AppState>,
374    req: Request<Body>,
375    next: Next,
376) -> Response {
377    let mut resp = next.run(req).await;
378    let h = resp.headers_mut();
379    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
380    h.insert(
381        "X-Content-Type-Options",
382        HeaderValue::from_static("nosniff"),
383    );
384    h.insert(
385        "Referrer-Policy",
386        HeaderValue::from_static("strict-origin-when-cross-origin"),
387    );
388    h.insert(
389        "Content-Security-Policy",
390        HeaderValue::from_static(
391            "default-src 'self'; \
392             style-src 'self' 'unsafe-inline'; \
393             img-src 'self' data: blob:; \
394             script-src 'self' 'unsafe-inline'; \
395             font-src 'self' data:; \
396             object-src 'none'; \
397             frame-ancestors 'none'",
398        ),
399    );
400    h.insert(
401        "X-Permitted-Cross-Domain-Policies",
402        HeaderValue::from_static("none"),
403    );
404    h.insert(
405        "Permissions-Policy",
406        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
407    );
408    h.insert(
409        "Cross-Origin-Opener-Policy",
410        HeaderValue::from_static("same-origin"),
411    );
412    h.insert(
413        "Cross-Origin-Resource-Policy",
414        HeaderValue::from_static("same-origin"),
415    );
416    if state.tls_enabled {
417        h.insert(
418            "Strict-Transport-Security",
419            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
420        );
421    }
422    resp
423}
424
425async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
426    let ip = req
427        .extensions()
428        .get::<axum::extract::ConnectInfo<SocketAddr>>()
429        .map(|c| c.0.ip())
430        .or_else(|| {
431            req.headers()
432                .get("X-Forwarded-For")
433                .and_then(|v| v.to_str().ok())
434                .and_then(|s| s.split(',').next())
435                .and_then(|s| s.trim().parse::<IpAddr>().ok())
436        })
437        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
438
439    if !state.rate_limiter.is_allowed(ip) {
440        return (
441            StatusCode::TOO_MANY_REQUESTS,
442            [(header::RETRY_AFTER, "60")],
443            "429 Too Many Requests\n",
444        )
445            .into_response();
446    }
447    next.run(req).await
448}
449
450async fn splash() -> impl IntoResponse {
451    let template = SplashTemplate {};
452    Html(
453        template
454            .render()
455            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
456    )
457}
458
459async fn index(Query(query): Query<IndexQuery>) -> impl IntoResponse {
460    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
461        let policy = query
462            .mixed_line_policy
463            .unwrap_or_else(|| "code_only".to_string());
464        let behavior = query
465            .binary_file_behavior
466            .unwrap_or_else(|| "skip".to_string());
467        let cfg = ScanConfig {
468            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
469            path: query.path.unwrap_or_default(),
470            include_globs: query.include_globs.unwrap_or_default(),
471            exclude_globs: query.exclude_globs.unwrap_or_default(),
472            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
473            mixed_line_policy: policy,
474            python_docstrings_as_comments: query
475                .python_docstrings_as_comments
476                .as_deref()
477                .map(|v| v != "off")
478                .unwrap_or(true),
479            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
480            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
481            vendor_directory_detection: query.vendor_directory_detection.as_deref()
482                != Some("disabled"),
483            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
484            binary_file_behavior: behavior,
485            output_dir: query.output_dir.unwrap_or_default(),
486            report_title: query.report_title.unwrap_or_default(),
487            generate_html: query
488                .generate_html
489                .as_deref()
490                .map(|v| v != "off")
491                .unwrap_or(true),
492            generate_pdf: query.generate_pdf.as_deref() == Some("on"),
493        };
494        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
495    } else {
496        "{}".to_string()
497    };
498
499    let template = IndexTemplate {
500        version: env!("CARGO_PKG_VERSION"),
501        prefill_json,
502    };
503
504    Html(
505        template
506            .render()
507            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
508    )
509}
510
511async fn scan_setup_handler(State(state): State<AppState>) -> impl IntoResponse {
512    let recent_scans_json = {
513        let reg = state.registry.lock().await;
514        let arr: Vec<serde_json::Value> = reg
515            .entries
516            .iter()
517            .rev()
518            .take(6)
519            .map(|e| {
520                let run_dir = e
521                    .html_path
522                    .as_ref()
523                    .or(e.json_path.as_ref())
524                    .and_then(|p| p.parent().map(PathBuf::from));
525                let config_val: Option<serde_json::Value> = run_dir
526                    .map(|d| d.join("scan-config.json"))
527                    .filter(|p| p.exists())
528                    .and_then(|p| fs::read_to_string(&p).ok())
529                    .and_then(|s| serde_json::from_str(&s).ok());
530                serde_json::json!({
531                    "project_label": e.project_label,
532                    "timestamp": fmt_pst(e.timestamp_utc),
533                    "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
534                    "config": config_val,
535                })
536            })
537            .collect();
538        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
539    };
540
541    let template = ScanSetupTemplate { recent_scans_json };
542    Html(
543        template
544            .render()
545            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
546    )
547}
548
549async fn healthz() -> &'static str {
550    "ok"
551}
552
553async fn chart_js_handler() -> impl IntoResponse {
554    (
555        [(
556            header::CONTENT_TYPE,
557            "application/javascript; charset=utf-8",
558        )],
559        CHART_JS,
560    )
561}
562
563#[derive(Debug, Deserialize)]
564struct AnalyzeForm {
565    path: String,
566    mixed_line_policy: Option<MixedLinePolicy>,
567    python_docstrings_as_comments: Option<String>,
568    generated_file_detection: Option<String>,
569    minified_file_detection: Option<String>,
570    vendor_directory_detection: Option<String>,
571    include_lockfiles: Option<String>,
572    binary_file_behavior: Option<BinaryFileBehavior>,
573    output_dir: Option<String>,
574    report_title: Option<String>,
575    generate_html: Option<String>,
576    generate_pdf: Option<String>,
577    include_globs: Option<String>,
578    exclude_globs: Option<String>,
579    submodule_breakdown: Option<String>,
580}
581
582#[derive(Debug, Serialize, Deserialize, Clone)]
583struct ScanConfig {
584    oxide_sloc_version: String,
585    path: String,
586    include_globs: String,
587    exclude_globs: String,
588    submodule_breakdown: bool,
589    mixed_line_policy: String,
590    python_docstrings_as_comments: bool,
591    generated_file_detection: bool,
592    minified_file_detection: bool,
593    vendor_directory_detection: bool,
594    include_lockfiles: bool,
595    binary_file_behavior: String,
596    output_dir: String,
597    report_title: String,
598    generate_html: bool,
599    generate_pdf: bool,
600}
601
602#[derive(Debug, Deserialize, Default)]
603struct IndexQuery {
604    path: Option<String>,
605    include_globs: Option<String>,
606    exclude_globs: Option<String>,
607    submodule_breakdown: Option<String>,
608    mixed_line_policy: Option<String>,
609    python_docstrings_as_comments: Option<String>,
610    generated_file_detection: Option<String>,
611    minified_file_detection: Option<String>,
612    vendor_directory_detection: Option<String>,
613    include_lockfiles: Option<String>,
614    binary_file_behavior: Option<String>,
615    output_dir: Option<String>,
616    report_title: Option<String>,
617    generate_html: Option<String>,
618    generate_pdf: Option<String>,
619    prefilled: Option<String>,
620}
621
622#[derive(Debug, Deserialize)]
623struct PreviewQuery {
624    path: Option<String>,
625    include_globs: Option<String>,
626    exclude_globs: Option<String>,
627}
628
629#[derive(Debug, Deserialize)]
630struct PickDirectoryQuery {
631    kind: Option<String>,
632    current: Option<String>,
633}
634
635#[derive(Debug, Deserialize, Default)]
636struct ArtifactQuery {
637    download: Option<String>,
638}
639
640#[derive(Debug, Serialize)]
641struct PickDirectoryResponse {
642    selected_path: Option<String>,
643    cancelled: bool,
644}
645
646async fn pick_directory_handler(
647    State(state): State<AppState>,
648    Query(query): Query<PickDirectoryQuery>,
649) -> Response {
650    if state.server_mode {
651        return StatusCode::NOT_FOUND.into_response();
652    }
653
654    let title = match query.kind.as_deref() {
655        Some("output") => "Select output directory",
656        _ => "Select project directory",
657    };
658
659    let mut dialog = rfd::FileDialog::new().set_title(title);
660    if let Some(current) = query.current.as_deref() {
661        let resolved = resolve_input_path(current);
662        let seed = if resolved.is_dir() {
663            Some(resolved)
664        } else {
665            resolved.parent().map(Path::to_path_buf)
666        };
667        if let Some(seed_dir) = seed.filter(|p| p.exists()) {
668            dialog = dialog.set_directory(seed_dir);
669        }
670    }
671
672    let picked = dialog.pick_folder();
673
674    Json(PickDirectoryResponse {
675        selected_path: picked.as_ref().map(|p| display_path(p)),
676        cancelled: picked.is_none(),
677    })
678    .into_response()
679}
680
681async fn pick_file_handler(State(state): State<AppState>) -> Response {
682    if state.server_mode {
683        return StatusCode::NOT_FOUND.into_response();
684    }
685    let picked = rfd::FileDialog::new()
686        .set_title("Select HTML report")
687        .add_filter("HTML report", &["html"])
688        .pick_file();
689    Json(PickDirectoryResponse {
690        selected_path: picked.as_ref().map(|p| display_path(p)),
691        cancelled: picked.is_none(),
692    })
693    .into_response()
694}
695
696#[derive(Deserialize)]
697struct LocateReportForm {
698    file_path: String,
699}
700
701async fn locate_report_handler(
702    State(state): State<AppState>,
703    Form(form): Form<LocateReportForm>,
704) -> impl IntoResponse {
705    let file_ext = Path::new(&form.file_path)
706        .extension()
707        .and_then(|e| e.to_str())
708        .unwrap_or("")
709        .to_ascii_lowercase();
710    if file_ext != "html" {
711        let html = ErrorTemplate {
712            message: "Only .html report files can be located via this form.".to_string(),
713            last_report_url: Some("/view-reports".to_string()),
714            last_report_label: Some("View Reports".to_string()),
715        }
716        .render()
717        .unwrap_or_else(|_| "<pre>Invalid file type.</pre>".to_string());
718        return Html(html).into_response();
719    }
720    let html_path = match fs::canonicalize(PathBuf::from(&form.file_path)) {
721        Ok(p) => strip_unc_prefix(p),
722        Err(_) => {
723            let html = ErrorTemplate {
724                message: "Report file not found or path is invalid.".to_string(),
725                last_report_url: Some("/view-reports".to_string()),
726                last_report_label: Some("View Reports".to_string()),
727            }
728            .render()
729            .unwrap_or_else(|_| "<pre>Invalid path.</pre>".to_string());
730            return Html(html).into_response();
731        }
732    };
733    // In server mode, only accept reports within the configured output directory.
734    if state.server_mode {
735        let output_root = resolve_output_root(None).unwrap_or_else(|_| PathBuf::from("out/web"));
736        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
737        if !html_path.starts_with(&canonical_root) {
738            let html = ErrorTemplate {
739                message: "Report file must be within the configured output directory.".to_string(),
740                last_report_url: Some("/view-reports".to_string()),
741                last_report_label: Some("View Reports".to_string()),
742            }
743            .render()
744            .unwrap_or_else(|_| "<pre>Invalid path.</pre>".to_string());
745            return Html(html).into_response();
746        }
747    }
748    let parent = match html_path.parent() {
749        Some(p) => p.to_path_buf(),
750        None => {
751            let html = ErrorTemplate {
752                message: "Report file has no parent directory.".to_string(),
753                last_report_url: Some("/view-reports".to_string()),
754                last_report_label: Some("View Reports".to_string()),
755            }
756            .render()
757            .unwrap_or_else(|_| "<pre>Invalid path.</pre>".to_string());
758            return Html(html).into_response();
759        }
760    };
761    let json_candidate = parent.join("result.json");
762    let mut reg = state.registry.lock().await;
763    // Find an existing entry whose output directory matches the selected file's parent.
764    let entry_idx = reg.entries.iter().position(|e| {
765        let json_match = e
766            .json_path
767            .as_ref()
768            .and_then(|p| p.parent())
769            .map(|p| p == parent)
770            .unwrap_or(false);
771        let html_match = e
772            .html_path
773            .as_ref()
774            .and_then(|p| p.parent())
775            .map(|p| p == parent)
776            .unwrap_or(false);
777        json_match || html_match
778    });
779    if let Some(idx) = entry_idx {
780        reg.entries[idx].html_path = Some(html_path);
781        let _ = reg.save(&state.registry_path);
782        return axum::response::Redirect::to("/view-reports?linked=1").into_response();
783    }
784    // No match — attempt to build an entry from an adjacent result.json.
785    if json_candidate.exists() {
786        match read_json(&json_candidate) {
787            Ok(run) => {
788                let project_label = run
789                    .input_roots
790                    .first()
791                    .map(|r| sanitize_project_label(r))
792                    .unwrap_or_else(|| "Unknown Project".to_string());
793                let entry = RegistryEntry {
794                    run_id: run.tool.run_id.clone(),
795                    timestamp_utc: run.tool.timestamp_utc,
796                    project_label,
797                    input_roots: run.input_roots.clone(),
798                    json_path: Some(json_candidate),
799                    html_path: Some(html_path),
800                    pdf_path: None,
801                    summary: ScanSummarySnapshot {
802                        files_analyzed: run.summary_totals.files_analyzed,
803                        files_skipped: run.summary_totals.files_skipped,
804                        total_physical_lines: run.summary_totals.total_physical_lines,
805                        code_lines: run.summary_totals.code_lines,
806                        comment_lines: run.summary_totals.comment_lines,
807                        blank_lines: run.summary_totals.blank_lines,
808                        functions: run.summary_totals.functions,
809                        classes: run.summary_totals.classes,
810                        variables: run.summary_totals.variables,
811                        imports: run.summary_totals.imports,
812                    },
813                    git_branch: None,
814                    git_commit: None,
815                    git_author: None,
816                    git_tags: None,
817                };
818                reg.add_entry(entry);
819                let _ = reg.save(&state.registry_path);
820                return axum::response::Redirect::to("/view-reports?linked=1").into_response();
821            }
822            Err(e) => {
823                let file_hint = if state.server_mode {
824                    String::new()
825                } else {
826                    format!("\n\nFile: {}\n\nError: {e}", json_candidate.display())
827                };
828                let html = ErrorTemplate {
829                    message: format!(
830                        "Could not link this report.\n\nA 'result.json' was found but could not \
831                         be parsed — it may have been saved by an older version of OxideSLOC. \
832                         Re-running the analysis will create a fresh, compatible record.{file_hint}"
833                    ),
834                    last_report_url: Some("/view-reports".to_string()),
835                    last_report_label: Some("View Reports".to_string()),
836                }
837                .render()
838                .unwrap_or_else(|_| "<pre>Link failed.</pre>".to_string());
839                return Html(html).into_response();
840            }
841        }
842    }
843    let file_hint = if state.server_mode {
844        String::new()
845    } else {
846        format!("\n\nFile: {}", html_path.display())
847    };
848    let html = ErrorTemplate {
849        message: format!(
850            "Could not link this report.\n\nNo matching scan record was found, and no \
851             'result.json' was found in the same folder.{file_hint}"
852        ),
853        last_report_url: Some("/view-reports".to_string()),
854        last_report_label: Some("View Reports".to_string()),
855    }
856    .render()
857    .unwrap_or_else(|_| "<pre>Link failed.</pre>".to_string());
858    Html(html).into_response()
859}
860
861#[derive(Debug, Deserialize)]
862struct OpenPathQuery {
863    path: Option<String>,
864}
865
866async fn open_path_handler(
867    State(state): State<AppState>,
868    Query(query): Query<OpenPathQuery>,
869) -> impl IntoResponse {
870    if state.server_mode {
871        return StatusCode::NOT_FOUND.into_response();
872    }
873    let raw = match query.path.as_deref() {
874        Some(p) if !p.is_empty() => p,
875        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
876    };
877
878    let canonical = match fs::canonicalize(raw) {
879        Ok(p) => p,
880        Err(_) => return (StatusCode::BAD_REQUEST, "path not found").into_response(),
881    };
882
883    // Must be a directory (or a file whose parent directory we open).
884    let target = if canonical.is_file() {
885        match canonical.parent() {
886            Some(p) => p.to_path_buf(),
887            None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
888        }
889    } else if canonical.is_dir() {
890        canonical
891    } else {
892        // Block special devices, pipes, sockets, etc.
893        return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response();
894    };
895
896    #[cfg(target_os = "windows")]
897    let _ = std::process::Command::new("explorer.exe")
898        .arg(&target)
899        .stdout(Stdio::null())
900        .stderr(Stdio::null())
901        .spawn();
902    #[cfg(target_os = "macos")]
903    let _ = std::process::Command::new("open")
904        .arg(&target)
905        .stdout(Stdio::null())
906        .stderr(Stdio::null())
907        .spawn();
908    #[cfg(target_os = "linux")]
909    let _ = std::process::Command::new("xdg-open")
910        .arg(&target)
911        .stdout(Stdio::null())
912        .stderr(Stdio::null())
913        .spawn();
914
915    (StatusCode::OK, "ok").into_response()
916}
917
918async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
919    let safe_folder = match folder.as_str() {
920        "icons" | "logo" => folder,
921        _ => return StatusCode::NOT_FOUND.into_response(),
922    };
923
924    let safe_name = Path::new(&file)
925        .file_name()
926        .and_then(|name| name.to_str())
927        .unwrap_or("");
928
929    if safe_name.is_empty() {
930        return StatusCode::NOT_FOUND.into_response();
931    }
932
933    let ext = Path::new(safe_name)
934        .extension()
935        .and_then(|e| e.to_str())
936        .unwrap_or("")
937        .to_ascii_lowercase();
938
939    let content_type = match ext.as_str() {
940        "png" => "image/png",
941        "jpg" | "jpeg" => "image/jpeg",
942        "webp" => "image/webp",
943        "svg" => "image/svg+xml",
944        _ => return StatusCode::NOT_FOUND.into_response(),
945    };
946
947    let path = workspace_root()
948        .join("images")
949        .join(safe_folder)
950        .join(safe_name);
951    match fs::read(path) {
952        Ok(bytes) => ([(header::CONTENT_TYPE, content_type)], bytes).into_response(),
953        Err(_) => StatusCode::NOT_FOUND.into_response(),
954    }
955}
956
957async fn preview_handler(
958    State(state): State<AppState>,
959    Query(query): Query<PreviewQuery>,
960) -> impl IntoResponse {
961    let raw_path = query.path.unwrap_or_else(|| "samples/basic".to_string());
962    let resolved = resolve_input_path(&raw_path);
963
964    if state.server_mode {
965        let config = &state.base_config;
966        if config.discovery.allowed_scan_roots.is_empty() {
967            return Html(
968                r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
969            );
970        }
971        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
972        let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
973            fs::canonicalize(root)
974                .ok()
975                .map(|r| canonical.starts_with(&r))
976                .unwrap_or(false)
977        });
978        if !allowed {
979            return Html(
980                r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
981            );
982        }
983    }
984
985    let include_patterns = split_patterns(query.include_globs.as_deref());
986    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
987
988    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
989        Ok(html) => Html(html),
990        Err(err) => Html(format!(
991            r#"<div class="preview-error">Preview failed: {}</div>"#,
992            escape_html(&err.to_string())
993        )),
994    }
995}
996
997async fn analyze_handler(
998    State(state): State<AppState>,
999    Form(form): Form<AnalyzeForm>,
1000) -> impl IntoResponse {
1001    let _permit = match Arc::clone(&state.analyze_semaphore).try_acquire_owned() {
1002        Ok(p) => p,
1003        Err(_) => {
1004            let template = ErrorTemplate {
1005                message:
1006                    "Server is busy — too many concurrent analyses. Please try again in a moment."
1007                        .to_string(),
1008                last_report_url: None,
1009                last_report_label: None,
1010            };
1011            return (
1012                StatusCode::SERVICE_UNAVAILABLE,
1013                Html(
1014                    template
1015                        .render()
1016                        .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
1017                ),
1018            )
1019                .into_response();
1020        }
1021    };
1022
1023    let mut config = state.base_config.clone();
1024    let resolved_path = resolve_input_path(&form.path);
1025
1026    if state.server_mode {
1027        if config.discovery.allowed_scan_roots.is_empty() {
1028            let template = ErrorTemplate {
1029                message: "Scan path rejected: no allowed_scan_roots configured on this server. \
1030                          Set allowed_scan_roots in the server config to permit scanning."
1031                    .to_string(),
1032                last_report_url: None,
1033                last_report_label: None,
1034            };
1035            return (
1036                StatusCode::FORBIDDEN,
1037                Html(
1038                    template
1039                        .render()
1040                        .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
1041                ),
1042            )
1043                .into_response();
1044        }
1045        let canonical = fs::canonicalize(&resolved_path).unwrap_or_else(|_| resolved_path.clone());
1046        let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
1047            fs::canonicalize(root)
1048                .ok()
1049                .map(|r| canonical.starts_with(&r))
1050                .unwrap_or(false)
1051        });
1052        if !allowed {
1053            let template = ErrorTemplate {
1054                message: "The requested path is not within an allowed scan directory.".to_string(),
1055                last_report_url: None,
1056                last_report_label: None,
1057            };
1058            return (
1059                StatusCode::FORBIDDEN,
1060                Html(
1061                    template
1062                        .render()
1063                        .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
1064                ),
1065            )
1066                .into_response();
1067        }
1068    }
1069    config.discovery.root_paths = vec![resolved_path];
1070
1071    if let Some(policy) = form.mixed_line_policy {
1072        config.analysis.mixed_line_policy = policy;
1073    }
1074
1075    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
1076    config.analysis.generated_file_detection =
1077        form.generated_file_detection.as_deref() != Some("disabled");
1078    config.analysis.minified_file_detection =
1079        form.minified_file_detection.as_deref() != Some("disabled");
1080    config.analysis.vendor_directory_detection =
1081        form.vendor_directory_detection.as_deref() != Some("disabled");
1082    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
1083
1084    if let Some(binary_behavior) = form.binary_file_behavior {
1085        config.analysis.binary_file_behavior = binary_behavior;
1086    }
1087
1088    if let Some(report_title) = form.report_title.as_deref() {
1089        let trimmed = report_title.trim();
1090        if !trimmed.is_empty() {
1091            config.reporting.report_title = trimmed.to_string();
1092        }
1093    }
1094
1095    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
1096    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
1097    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
1098
1099    // Auto-exclude the output directory so scan artifacts never appear in counts.
1100    // Resolve the output path early (before analysis) to determine the folder name.
1101    let project_root_for_exclude = resolve_input_path(&form.path);
1102    let raw_out = form.output_dir.as_deref().unwrap_or("").trim();
1103    let resolved_out_early = if raw_out.is_empty() {
1104        project_root_for_exclude.join("sloc")
1105    } else if Path::new(raw_out).is_absolute() {
1106        PathBuf::from(raw_out)
1107    } else {
1108        workspace_root().join(raw_out)
1109    };
1110    // If the resolved output root lives inside the project root, exclude its top-level name.
1111    if let Ok(rel) = resolved_out_early.strip_prefix(&project_root_for_exclude) {
1112        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
1113            let dir = first.to_string();
1114            if !config.discovery.excluded_directories.contains(&dir) {
1115                config.discovery.excluded_directories.push(dir);
1116            }
1117        }
1118    }
1119    // Always exclude the canonical "sloc" folder name regardless of where output lands.
1120    if !config
1121        .discovery
1122        .excluded_directories
1123        .iter()
1124        .any(|d| d == "sloc")
1125    {
1126        config
1127            .discovery
1128            .excluded_directories
1129            .push("sloc".to_string());
1130    }
1131
1132    let analysis_result =
1133        tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
1134            let run = analyze(&config, "serve")?;
1135            let html = render_html(&run)?;
1136            Ok((run, html))
1137        })
1138        .await
1139        .map_err(|err| anyhow::anyhow!(err.to_string()))
1140        .and_then(|result| result);
1141
1142    let (run, report_html) = match analysis_result {
1143        Ok(value) => value,
1144        Err(err) => {
1145            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
1146            let template = ErrorTemplate {
1147                message: "Analysis failed. Check that the path exists and is readable.".to_string(),
1148                last_report_url: None,
1149                last_report_label: None,
1150            };
1151            return Html(
1152                template
1153                    .render()
1154                    .unwrap_or_else(|_| "<pre>Analysis failed.</pre>".to_string()),
1155            )
1156            .into_response();
1157        }
1158    };
1159
1160    let run_id = run.tool.run_id.to_string();
1161
1162    // Capture the most-recent previous scan for this project before registering the current one.
1163    // Only consider entries whose json file still exists on disk.
1164    let prev_entry: Option<RegistryEntry> = {
1165        let reg = state.registry.lock().await;
1166        reg.entries_for_roots(&run.input_roots)
1167            .into_iter()
1168            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
1169            .cloned()
1170    };
1171
1172    // Git info is now captured inside analyze() and stored on the run.
1173    let git_branch = run.git_branch.clone();
1174    let git_commit = run.git_commit_short.clone();
1175    let git_author = run.git_commit_author.clone();
1176    let git_tags = run.git_tags.clone();
1177
1178    // Compute line-level delta vs the previous scan if JSON is available.
1179    let scan_delta = prev_entry.as_ref().and_then(|prev| {
1180        prev.json_path
1181            .as_ref()
1182            .and_then(|p| read_json(p).ok())
1183            .map(|prev_run| compute_delta(&prev_run, &run))
1184    });
1185    let prev_scan_count: usize = {
1186        let reg = state.registry.lock().await;
1187        reg.entries_for_roots(&run.input_roots)
1188            .iter()
1189            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
1190            .count()
1191    };
1192
1193    let output_root = match resolve_output_root(form.output_dir.as_deref()) {
1194        Ok(path) => path,
1195        Err(err) => {
1196            eprintln!("[oxide-sloc][analyze] output directory error: {err:#}");
1197            let template = ErrorTemplate {
1198                message: "Could not create output directory. Check the output path setting."
1199                    .to_string(),
1200                last_report_url: None,
1201                last_report_label: None,
1202            };
1203            return Html(
1204                template
1205                    .render()
1206                    .unwrap_or_else(|_| "<pre>Output directory error.</pre>".to_string()),
1207            )
1208            .into_response();
1209        }
1210    };
1211
1212    let project_label = sanitize_project_label(&form.path);
1213    let run_dir = output_root.join(format!("{}_{}", project_label, run_id));
1214
1215    let artifact_result = persist_run_artifacts(
1216        &run,
1217        &report_html,
1218        &run_dir,
1219        true, // JSON always generated so compare and diff are always available
1220        form.generate_html.is_some(),
1221        form.generate_pdf.is_some(),
1222        &run.effective_configuration.reporting.report_title,
1223    );
1224
1225    let (artifacts, pending_pdf) = match artifact_result {
1226        Ok(value) => value,
1227        Err(err) => {
1228            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
1229            let template = ErrorTemplate {
1230                message: "Failed to save report artifacts. Check available disk space.".to_string(),
1231                last_report_url: None,
1232                last_report_label: None,
1233            };
1234            return Html(
1235                template
1236                    .render()
1237                    .unwrap_or_else(|_| "<pre>Artifact write failed.</pre>".to_string()),
1238            )
1239            .into_response();
1240        }
1241    };
1242
1243    {
1244        let mut map = state.artifacts.lock().await;
1245        map.insert(run_id.clone(), artifacts.clone());
1246    }
1247
1248    // Persist entry to the on-disk registry.
1249    {
1250        let entry = RegistryEntry {
1251            run_id: run_id.clone(),
1252            timestamp_utc: run.tool.timestamp_utc,
1253            project_label: project_label.clone(),
1254            input_roots: run.input_roots.clone(),
1255            json_path: artifacts.json_path.clone(),
1256            html_path: artifacts.html_path.clone(),
1257            pdf_path: artifacts.pdf_path.clone(),
1258            summary: ScanSummarySnapshot {
1259                files_analyzed: run.summary_totals.files_analyzed,
1260                files_skipped: run.summary_totals.files_skipped,
1261                total_physical_lines: run.summary_totals.total_physical_lines,
1262                code_lines: run.summary_totals.code_lines,
1263                comment_lines: run.summary_totals.comment_lines,
1264                blank_lines: run.summary_totals.blank_lines,
1265                functions: run.summary_totals.functions,
1266                classes: run.summary_totals.classes,
1267                variables: run.summary_totals.variables,
1268                imports: run.summary_totals.imports,
1269            },
1270            git_branch: git_branch.clone(),
1271            git_commit: git_commit.clone(),
1272            git_author: git_author.clone(),
1273            git_tags: git_tags.clone(),
1274        };
1275        let mut reg = state.registry.lock().await;
1276        reg.add_entry(entry);
1277        let _ = reg.save(&state.registry_path);
1278    }
1279
1280    // Export scan-config.json alongside artifacts so users can reload settings later.
1281    {
1282        let policy_str = serde_json::to_value(form.mixed_line_policy)
1283            .ok()
1284            .filter(|v| !v.is_null())
1285            .and_then(|v| v.as_str().map(String::from))
1286            .unwrap_or_else(|| "code_only".to_string());
1287        let behavior_str = serde_json::to_value(form.binary_file_behavior)
1288            .ok()
1289            .filter(|v| !v.is_null())
1290            .and_then(|v| v.as_str().map(String::from))
1291            .unwrap_or_else(|| "skip".to_string());
1292        let scan_cfg = ScanConfig {
1293            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1294            path: form.path.clone(),
1295            include_globs: form.include_globs.clone().unwrap_or_default(),
1296            exclude_globs: form.exclude_globs.clone().unwrap_or_default(),
1297            submodule_breakdown: form.submodule_breakdown.as_deref() == Some("enabled"),
1298            mixed_line_policy: policy_str,
1299            python_docstrings_as_comments: form.python_docstrings_as_comments.is_some(),
1300            generated_file_detection: form.generated_file_detection.as_deref() != Some("disabled"),
1301            minified_file_detection: form.minified_file_detection.as_deref() != Some("disabled"),
1302            vendor_directory_detection: form.vendor_directory_detection.as_deref()
1303                != Some("disabled"),
1304            include_lockfiles: form.include_lockfiles.as_deref() == Some("enabled"),
1305            binary_file_behavior: behavior_str,
1306            output_dir: form.output_dir.clone().unwrap_or_default(),
1307            report_title: run.effective_configuration.reporting.report_title.clone(),
1308            generate_html: form.generate_html.is_some(),
1309            generate_pdf: form.generate_pdf.is_some(),
1310        };
1311        if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
1312            let _ = fs::write(run_dir.join("scan-config.json"), json);
1313        }
1314    }
1315
1316    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
1317        tokio::spawn(async move {
1318            let result = tokio::task::spawn_blocking(move || {
1319                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
1320                if cleanup_src {
1321                    let _ = fs::remove_file(&pdf_src);
1322                }
1323                r
1324            })
1325            .await;
1326            match result {
1327                Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
1328                Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
1329                Ok(Ok(())) => {}
1330            }
1331        });
1332    }
1333
1334    let language_rows = run
1335        .totals_by_language
1336        .iter()
1337        .map(|row| LanguageSummaryRow {
1338            language: row.language.display_name().to_string(),
1339            files: row.files,
1340            physical: row.total_physical_lines,
1341            code: row.code_lines,
1342            comments: row.comment_lines,
1343            blank: row.blank_lines,
1344            mixed: row.mixed_lines_separate,
1345            functions: row.functions,
1346            classes: row.classes,
1347            variables: row.variables,
1348            imports: row.imports,
1349        })
1350        .collect::<Vec<_>>();
1351
1352    let files_analyzed = run.per_file_records.len() as u64;
1353    let files_skipped = run.skipped_file_records.len() as u64;
1354    let physical_lines = language_rows.iter().map(|row| row.physical).sum::<u64>();
1355    let code_lines = language_rows.iter().map(|row| row.code).sum::<u64>();
1356    let comment_lines = language_rows.iter().map(|row| row.comments).sum::<u64>();
1357    let blank_lines = language_rows.iter().map(|row| row.blank).sum::<u64>();
1358    let mixed_lines = language_rows.iter().map(|row| row.mixed).sum::<u64>();
1359    let functions = language_rows.iter().map(|row| row.functions).sum::<u64>();
1360    let classes = language_rows.iter().map(|row| row.classes).sum::<u64>();
1361    let variables = language_rows.iter().map(|row| row.variables).sum::<u64>();
1362    let imports = language_rows.iter().map(|row| row.imports).sum::<u64>();
1363
1364    // Previous scan summary values for the metrics table Previous/Change columns.
1365    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
1366    let prev_fa = prev_sum.map(|s| s.files_analyzed);
1367    let prev_fs = prev_sum.map(|s| s.files_skipped);
1368    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
1369    let prev_cl = prev_sum.map(|s| s.code_lines);
1370    let prev_cml = prev_sum.map(|s| s.comment_lines);
1371    let prev_bl = prev_sum.map(|s| s.blank_lines);
1372    let fmt_prev = |opt: Option<u64>| opt.map(|v| v.to_string()).unwrap_or_else(|| "—".into());
1373    let prev_fa_str = fmt_prev(prev_fa);
1374    let prev_fs_str = fmt_prev(prev_fs);
1375    let prev_pl_str = fmt_prev(prev_pl);
1376    let prev_cl_str = fmt_prev(prev_cl);
1377    let prev_cml_str = fmt_prev(prev_cml);
1378    let prev_bl_str = fmt_prev(prev_bl);
1379    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
1380    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
1381    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
1382    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
1383    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
1384    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
1385    let delta_fa_class = delta_fa_class.to_string();
1386    let delta_fs_class = delta_fs_class.to_string();
1387    let delta_pl_class = delta_pl_class.to_string();
1388    let delta_cl_class = delta_cl_class.to_string();
1389    let delta_cml_class = delta_cml_class.to_string();
1390    let delta_bl_class = delta_bl_class.to_string();
1391
1392    // Pre-compute line-level deltas for the line change summary.
1393    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(|d| {
1394        d.file_deltas
1395            .iter()
1396            .map(|f| match f.status {
1397                sloc_core::FileChangeStatus::Added => f.current_code,
1398                sloc_core::FileChangeStatus::Modified => f.code_delta.max(0),
1399                _ => 0,
1400            })
1401            .sum()
1402    });
1403    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(|d| {
1404        d.file_deltas
1405            .iter()
1406            .map(|f| match f.status {
1407                sloc_core::FileChangeStatus::Removed => f.baseline_code,
1408                sloc_core::FileChangeStatus::Modified => (-f.code_delta).max(0),
1409                _ => 0,
1410            })
1411            .sum()
1412    });
1413    let (delta_lines_net_str, delta_lines_net_class) =
1414        match (delta_lines_added, delta_lines_removed) {
1415            (Some(a), Some(r)) => {
1416                let net = a - r;
1417                (fmt_delta(net), delta_class(net).to_string())
1418            }
1419            _ => ("—".to_string(), "na".to_string()),
1420        };
1421
1422    let template = ResultTemplate {
1423        report_title: run.effective_configuration.reporting.report_title.clone(),
1424        project_path: form.path,
1425        output_dir: display_path(&artifacts.output_dir),
1426        run_id: run_id.clone(),
1427        files_analyzed,
1428        files_skipped,
1429        physical_lines,
1430        code_lines,
1431        comment_lines,
1432        blank_lines,
1433        mixed_lines,
1434        functions,
1435        classes,
1436        variables,
1437        imports,
1438        html_url: artifacts
1439            .html_path
1440            .as_ref()
1441            .map(|_| format!("/runs/{run_id}/html")),
1442        pdf_url: artifacts
1443            .pdf_path
1444            .as_ref()
1445            .map(|_| format!("/runs/{run_id}/pdf")),
1446        json_url: artifacts
1447            .json_path
1448            .as_ref()
1449            .map(|_| format!("/runs/{run_id}/json")),
1450        html_download_url: artifacts
1451            .html_path
1452            .as_ref()
1453            .map(|_| format!("/runs/{run_id}/html?download=1")),
1454        pdf_download_url: artifacts
1455            .pdf_path
1456            .as_ref()
1457            .map(|_| format!("/runs/{run_id}/pdf?download=1")),
1458        json_download_url: artifacts
1459            .json_path
1460            .as_ref()
1461            .map(|_| format!("/runs/{run_id}/json?download=1")),
1462        html_path: artifacts.html_path.as_ref().map(|path| display_path(path)),
1463        pdf_path: artifacts.pdf_path.as_ref().map(|path| display_path(path)),
1464        json_path: artifacts.json_path.as_ref().map(|path| display_path(path)),
1465        language_rows,
1466        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
1467        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_pst(e.timestamp_utc)),
1468        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
1469        prev_fa_str,
1470        prev_fs_str,
1471        prev_pl_str,
1472        prev_cl_str,
1473        prev_cml_str,
1474        prev_bl_str,
1475        delta_fa_str,
1476        delta_fa_class,
1477        delta_fs_str,
1478        delta_fs_class,
1479        delta_pl_str,
1480        delta_pl_class,
1481        delta_cl_str,
1482        delta_cl_class,
1483        delta_cml_str,
1484        delta_cml_class,
1485        delta_bl_str,
1486        delta_bl_class,
1487        // delta metrics derived from the comparison against the previous scan
1488        delta_lines_added,
1489        delta_lines_removed,
1490        delta_lines_net_str,
1491        delta_lines_net_class,
1492        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
1493        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
1494        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
1495        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
1496        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
1497            d.file_deltas
1498                .iter()
1499                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
1500                .map(|f| f.current_code as u64)
1501                .sum()
1502        }),
1503        git_branch: git_branch.clone(),
1504        git_commit: git_commit.clone(),
1505        git_author: git_author.clone(),
1506        current_scan_number: prev_scan_count + 1,
1507        prev_scan_count,
1508        submodule_rows: run
1509            .submodule_summaries
1510            .iter()
1511            .map(|s| {
1512                let safe = sanitize_project_label(&s.name);
1513                let artifact_key = format!("sub_{}", safe);
1514                let html_url = if run.effective_configuration.discovery.submodule_breakdown
1515                    && form.generate_html.is_some()
1516                {
1517                    let parent_path = run.input_roots.first().map(|s| s.as_str()).unwrap_or("");
1518                    let sub_run = build_sub_run(&run, s, parent_path);
1519                    if let Ok(sub_html) = render_sub_report_html(&sub_run) {
1520                        let path = run_dir.join(format!("{}.html", artifact_key));
1521                        if fs::write(&path, sub_html.as_bytes()).is_ok() {
1522                            Some(format!("/runs/{}/{}", run_id, artifact_key))
1523                        } else {
1524                            None
1525                        }
1526                    } else {
1527                        None
1528                    }
1529                } else {
1530                    None
1531                };
1532                SubmoduleRow {
1533                    name: s.name.clone(),
1534                    relative_path: s.relative_path.clone(),
1535                    files_analyzed: s.files_analyzed,
1536                    code_lines: s.code_lines,
1537                    comment_lines: s.comment_lines,
1538                    blank_lines: s.blank_lines,
1539                    total_physical_lines: s.total_physical_lines,
1540                    html_url,
1541                }
1542            })
1543            .collect(),
1544        scan_config_url: format!("/runs/{run_id}/scan-config"),
1545    };
1546
1547    Html(
1548        template
1549            .render()
1550            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1551    )
1552    .into_response()
1553}
1554
1555fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
1556    let slug: String = report_title
1557        .chars()
1558        .map(|c| {
1559            if c.is_alphanumeric() || c == '-' {
1560                c.to_ascii_lowercase()
1561            } else {
1562                '_'
1563            }
1564        })
1565        .collect::<String>()
1566        .split('_')
1567        .filter(|s| !s.is_empty())
1568        .collect::<Vec<_>>()
1569        .join("_");
1570
1571    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
1572
1573    if slug.is_empty() {
1574        format!("report_{short_id}.pdf")
1575    } else {
1576        format!("{slug}_{short_id}.pdf")
1577    }
1578}
1579
1580async fn artifact_handler(
1581    State(state): State<AppState>,
1582    AxumPath((run_id, artifact)): AxumPath<(String, String)>,
1583    Query(query): Query<ArtifactQuery>,
1584) -> Response {
1585    let artifact_set = {
1586        let registry = state.artifacts.lock().await;
1587        registry.get(&run_id).cloned()
1588    };
1589
1590    // Fall back to the persisted registry when the server was restarted and the
1591    // in-memory artifact map no longer holds the entry.
1592    let artifact_set = match artifact_set {
1593        Some(a) => a,
1594        None => {
1595            let reg = state.registry.lock().await;
1596            match reg.find_by_run_id(&run_id) {
1597                Some(entry) => {
1598                    let output_dir = entry
1599                        .html_path
1600                        .as_ref()
1601                        .or(entry.json_path.as_ref())
1602                        .or(entry.pdf_path.as_ref())
1603                        .and_then(|p| p.parent().map(PathBuf::from))
1604                        .unwrap_or_default();
1605                    // Recover pdf_path: use the persisted one, or look for report.pdf
1606                    // adjacent to html/json if only the old entries lack it.
1607                    let pdf_path = entry.pdf_path.clone().or_else(|| {
1608                        let candidate = output_dir.join("report.pdf");
1609                        if candidate.exists() {
1610                            Some(candidate)
1611                        } else {
1612                            None
1613                        }
1614                    });
1615                    RunArtifacts {
1616                        output_dir,
1617                        html_path: entry.html_path.clone(),
1618                        pdf_path,
1619                        json_path: entry.json_path.clone(),
1620                        report_title: entry.project_label.clone(),
1621                    }
1622                }
1623                None => {
1624                    let error_html = ErrorTemplate {
1625                        message: format!(
1626                            "Report not found. Run ID {} is not in the scan history. \
1627                             The report may have been deleted, or this is an old run from \
1628                             before the scan registry was introduced.",
1629                            &run_id[..run_id.len().min(8)]
1630                        ),
1631                        last_report_url: Some("/view-reports".to_string()),
1632                        last_report_label: Some("View Reports".to_string()),
1633                    }
1634                    .render()
1635                    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
1636                    return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
1637                }
1638            }
1639        }
1640    };
1641
1642    let wants_download = matches!(
1643        query.download.as_deref(),
1644        Some("1") | Some("true") | Some("yes")
1645    );
1646
1647    match artifact.as_str() {
1648        "html" => {
1649            let Some(path) = artifact_set.html_path else {
1650                return StatusCode::NOT_FOUND.into_response();
1651            };
1652
1653            match fs::read_to_string(&path) {
1654                Ok(content) => {
1655                    if wants_download {
1656                        (
1657                            [
1658                                (header::CONTENT_TYPE, "text/html; charset=utf-8"),
1659                                (
1660                                    header::CONTENT_DISPOSITION,
1661                                    "attachment; filename=report.html",
1662                                ),
1663                            ],
1664                            content,
1665                        )
1666                            .into_response()
1667                    } else {
1668                        Html(content).into_response()
1669                    }
1670                }
1671                Err(err) => {
1672                    let filename = path
1673                        .file_name()
1674                        .map(|n| n.to_string_lossy().into_owned())
1675                        .unwrap_or_else(|| "report.html".to_string());
1676                    let msg = format!(
1677                        "HTML report '{filename}' could not be read.\n\n\
1678                         Error: {err}\n\n\
1679                         If you moved or renamed the output folder, the stored path is now stale. \
1680                         Use 'Open HTML folder' from the results page to browse the output directory."
1681                    );
1682                    let html = ErrorTemplate {
1683                        message: msg,
1684                        last_report_url: Some("/view-reports".to_string()),
1685                        last_report_label: Some("View Reports".to_string()),
1686                    }
1687                    .render()
1688                    .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
1689                    (StatusCode::NOT_FOUND, Html(html)).into_response()
1690                }
1691            }
1692        }
1693        "pdf" => {
1694            let Some(path) = artifact_set.pdf_path else {
1695                let msg = "PDF report was not generated for this run, or was not recorded in \
1696                           the scan registry. Re-run the analysis with PDF output enabled."
1697                    .to_string();
1698                let html = ErrorTemplate {
1699                    message: msg,
1700                    last_report_url: Some("/view-reports".to_string()),
1701                    last_report_label: Some("View Reports".to_string()),
1702                }
1703                .render()
1704                .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
1705                return (StatusCode::NOT_FOUND, Html(html)).into_response();
1706            };
1707
1708            match fs::read(&path) {
1709                Ok(bytes) => {
1710                    let filename = build_pdf_filename(&artifact_set.report_title, &run_id);
1711                    let disposition = if wants_download {
1712                        format!("attachment; filename=\"{}\"", filename)
1713                    } else {
1714                        format!("inline; filename=\"{}\"", filename)
1715                    };
1716                    (
1717                        [
1718                            (header::CONTENT_TYPE, "application/pdf".to_string()),
1719                            (header::CONTENT_DISPOSITION, disposition),
1720                        ],
1721                        bytes,
1722                    )
1723                        .into_response()
1724                }
1725                Err(err) => {
1726                    let filename = path
1727                        .file_name()
1728                        .map(|n| n.to_string_lossy().into_owned())
1729                        .unwrap_or_else(|| "report.pdf".to_string());
1730                    let msg = format!(
1731                        "PDF report '{filename}' could not be read.\n\n\
1732                         Error: {err}\n\n\
1733                         If you moved or renamed the output folder, the stored path is now stale. \
1734                         Use 'Open PDF folder' from the results page to browse the output directory."
1735                    );
1736                    let html = ErrorTemplate {
1737                        message: msg,
1738                        last_report_url: Some("/view-reports".to_string()),
1739                        last_report_label: Some("View Reports".to_string()),
1740                    }
1741                    .render()
1742                    .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
1743                    (StatusCode::NOT_FOUND, Html(html)).into_response()
1744                }
1745            }
1746        }
1747        "json" => {
1748            let Some(path) = artifact_set.json_path else {
1749                let msg = "JSON result was not generated for this run, or was not recorded in \
1750                           the scan registry. Re-run the analysis with JSON output enabled."
1751                    .to_string();
1752                let html = ErrorTemplate {
1753                    message: msg,
1754                    last_report_url: Some("/view-reports".to_string()),
1755                    last_report_label: Some("View Reports".to_string()),
1756                }
1757                .render()
1758                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
1759                return (StatusCode::NOT_FOUND, Html(html)).into_response();
1760            };
1761
1762            match fs::read(&path) {
1763                Ok(bytes) => {
1764                    if wants_download {
1765                        (
1766                            [
1767                                (header::CONTENT_TYPE, "application/json; charset=utf-8"),
1768                                (
1769                                    header::CONTENT_DISPOSITION,
1770                                    "attachment; filename=result.json",
1771                                ),
1772                            ],
1773                            bytes,
1774                        )
1775                            .into_response()
1776                    } else {
1777                        (
1778                            [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
1779                            bytes,
1780                        )
1781                            .into_response()
1782                    }
1783                }
1784                Err(err) => {
1785                    let filename = path
1786                        .file_name()
1787                        .map(|n| n.to_string_lossy().into_owned())
1788                        .unwrap_or_else(|| "result.json".to_string());
1789                    let msg = format!(
1790                        "JSON result '{filename}' could not be read.\n\n\
1791                         Error: {err}\n\n\
1792                         If you moved or renamed the output folder, the stored path is now stale. \
1793                         Use 'Open JSON folder' from the results page to browse the output directory."
1794                    );
1795                    let html = ErrorTemplate {
1796                        message: msg,
1797                        last_report_url: Some("/view-reports".to_string()),
1798                        last_report_label: Some("View Reports".to_string()),
1799                    }
1800                    .render()
1801                    .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
1802                    (StatusCode::NOT_FOUND, Html(html)).into_response()
1803                }
1804            }
1805        }
1806        "scan-config" => {
1807            let path = artifact_set.output_dir.join("scan-config.json");
1808            match fs::read(&path) {
1809                Ok(bytes) => (
1810                    [
1811                        (
1812                            header::CONTENT_TYPE,
1813                            "application/json; charset=utf-8".to_string(),
1814                        ),
1815                        (
1816                            header::CONTENT_DISPOSITION,
1817                            "attachment; filename=\"scan-config.json\"".to_string(),
1818                        ),
1819                    ],
1820                    bytes,
1821                )
1822                    .into_response(),
1823                Err(_) => StatusCode::NOT_FOUND.into_response(),
1824            }
1825        }
1826        _ if artifact.starts_with("sub_") => {
1827            if artifact.len() > 128
1828                || !artifact
1829                    .chars()
1830                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1831            {
1832                return StatusCode::BAD_REQUEST.into_response();
1833            }
1834            let filename = format!("{}.html", artifact);
1835            let path = artifact_set.output_dir.join(&filename);
1836            match fs::read_to_string(&path) {
1837                Ok(content) => Html(content).into_response(),
1838                Err(_) => {
1839                    let html = ErrorTemplate {
1840                        message: format!(
1841                            "Sub-report '{}' was not found in the run directory.\n\
1842                             Re-run the analysis with 'Detect and separate git submodules' \
1843                             and HTML output enabled.",
1844                            artifact
1845                        ),
1846                        last_report_url: Some("/view-reports".to_string()),
1847                        last_report_label: Some("View Reports".to_string()),
1848                    }
1849                    .render()
1850                    .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
1851                    (StatusCode::NOT_FOUND, Html(html)).into_response()
1852                }
1853            }
1854        }
1855        _ => StatusCode::NOT_FOUND.into_response(),
1856    }
1857}
1858
1859// ── History ───────────────────────────────────────────────────────────────────
1860
1861struct HistoryEntryRow {
1862    run_id: String,
1863    run_id_short: String,
1864    timestamp: String,
1865    project_label: String,
1866    project_path: String,
1867    files_analyzed: u64,
1868    files_skipped: u64,
1869    code_lines: u64,
1870    comment_lines: u64,
1871    blank_lines: u64,
1872    functions: u64,
1873    classes: u64,
1874    variables: u64,
1875    imports: u64,
1876    git_branch: String,
1877    git_commit: String,
1878    has_html: bool,
1879    has_json: bool,
1880    has_pdf: bool,
1881}
1882
1883fn fmt_pst(dt: chrono::DateTime<chrono::Utc>) -> String {
1884    dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset is always valid"))
1885        .format("%Y-%m-%d %H:%M PST")
1886        .to_string()
1887}
1888
1889fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
1890    reg.entries
1891        .iter()
1892        .map(|e| HistoryEntryRow {
1893            run_id: e.run_id.clone(),
1894            run_id_short: e
1895                .run_id
1896                .split('-')
1897                .next_back()
1898                .unwrap_or(&e.run_id)
1899                .chars()
1900                .take(7)
1901                .collect(),
1902            timestamp: fmt_pst(e.timestamp_utc),
1903            project_label: e.project_label.clone(),
1904            project_path: e
1905                .input_roots
1906                .first()
1907                .map(|s| sanitize_path_str(s))
1908                .unwrap_or_default(),
1909            files_analyzed: e.summary.files_analyzed,
1910            files_skipped: e.summary.files_skipped,
1911            code_lines: e.summary.code_lines,
1912            comment_lines: e.summary.comment_lines,
1913            blank_lines: e.summary.blank_lines,
1914            functions: e.summary.functions,
1915            classes: e.summary.classes,
1916            variables: e.summary.variables,
1917            imports: e.summary.imports,
1918            git_branch: e.git_branch.clone().unwrap_or_default(),
1919            git_commit: e.git_commit.clone().unwrap_or_default(),
1920            has_html: e.html_path.as_ref().map(|p| p.exists()).unwrap_or(false),
1921            has_json: e.json_path.as_ref().map(|p| p.exists()).unwrap_or(false),
1922            has_pdf: e.pdf_path.as_ref().map(|p| p.exists()).unwrap_or(false),
1923        })
1924        .collect()
1925}
1926
1927#[derive(Deserialize, Default)]
1928struct HistoryQuery {
1929    linked: Option<String>,
1930}
1931
1932async fn history_handler(
1933    State(state): State<AppState>,
1934    Query(query): Query<HistoryQuery>,
1935) -> impl IntoResponse {
1936    let mut entries = {
1937        let reg = state.registry.lock().await;
1938        make_history_rows(&reg)
1939    };
1940    entries.retain(|e| e.has_html);
1941    let total_scans = entries.len();
1942    let linked = query.linked.as_deref() == Some("1");
1943    let template = HistoryTemplate {
1944        entries,
1945        total_scans,
1946        linked,
1947    };
1948    Html(
1949        template
1950            .render()
1951            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1952    )
1953    .into_response()
1954}
1955
1956async fn compare_select_handler(State(state): State<AppState>) -> impl IntoResponse {
1957    let mut entries = {
1958        let reg = state.registry.lock().await;
1959        make_history_rows(&reg)
1960    };
1961    entries.retain(|e| e.has_json);
1962    let total_scans = entries.len();
1963    let template = CompareSelectTemplate {
1964        entries,
1965        total_scans,
1966    };
1967    Html(
1968        template
1969            .render()
1970            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1971    )
1972    .into_response()
1973}
1974
1975// ── Compare ───────────────────────────────────────────────────────────────────
1976
1977#[derive(Deserialize, Default)]
1978struct CompareQuery {
1979    a: Option<String>,
1980    b: Option<String>,
1981}
1982
1983struct CompareFileDeltaRow {
1984    relative_path: String,
1985    language: String,
1986    status: String,
1987    baseline_code: i64,
1988    current_code: i64,
1989    code_delta_str: String,
1990    code_delta_class: String,
1991    comment_delta_str: String,
1992    comment_delta_class: String,
1993    total_delta_str: String,
1994    total_delta_class: String,
1995}
1996
1997fn fmt_delta(n: i64) -> String {
1998    if n > 0 {
1999        format!("+{n}")
2000    } else {
2001        format!("{n}")
2002    }
2003}
2004
2005fn delta_class(n: i64) -> &'static str {
2006    if n > 0 {
2007        "pos"
2008    } else if n < 0 {
2009        "neg"
2010    } else {
2011        "zero"
2012    }
2013}
2014
2015/// Returns (display_string, css_class) for a numeric change column cell.
2016fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
2017    match prev {
2018        Some(p) => {
2019            let d = curr as i64 - p as i64;
2020            (fmt_delta(d), delta_class(d))
2021        }
2022        None => ("—".to_string(), "na"),
2023    }
2024}
2025
2026async fn compare_handler(
2027    State(state): State<AppState>,
2028    Query(query): Query<CompareQuery>,
2029) -> impl IntoResponse {
2030    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
2031    // redirect to the history page where the user can select two runs.
2032    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
2033        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
2034        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
2035    };
2036
2037    let (maybe_a, maybe_b) = {
2038        let reg = state.registry.lock().await;
2039        (
2040            reg.find_by_run_id(&run_id_a).cloned(),
2041            reg.find_by_run_id(&run_id_b).cloned(),
2042        )
2043    };
2044
2045    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
2046        let html = ErrorTemplate {
2047            message: "One or both run IDs were not found in scan history. \
2048                      The runs may have been deleted or the registry may have been reset."
2049                .to_string(),
2050            last_report_url: Some("/compare-scans".to_string()),
2051            last_report_label: Some("Compare Scans".to_string()),
2052        }
2053        .render()
2054        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
2055        return Html(html).into_response();
2056    };
2057
2058    // Ensure older scan is always the baseline.
2059    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
2060        (entry_a, entry_b)
2061    } else {
2062        (entry_b, entry_a)
2063    };
2064
2065    // If query params were in the wrong order, redirect to canonical URL so the
2066    // browser always shows the same URL for the same two scans regardless of how
2067    // the user arrived here (Full diff button vs. Compare Scans selection).
2068    if baseline_entry.run_id != run_id_a {
2069        let canonical = format!(
2070            "/compare?a={}&b={}",
2071            baseline_entry.run_id, current_entry.run_id
2072        );
2073        return axum::response::Redirect::to(&canonical).into_response();
2074    }
2075
2076    let (Some(base_json), Some(curr_json)) = (
2077        baseline_entry.json_path.as_ref(),
2078        current_entry.json_path.as_ref(),
2079    ) else {
2080        let html = ErrorTemplate {
2081            message: "Full comparison requires JSON scan data, which was not saved for one or \
2082                      both of these runs. JSON is now always saved for new scans — re-run the \
2083                      affected projects to enable comparisons."
2084                .to_string(),
2085            last_report_url: Some("/compare-scans".to_string()),
2086            last_report_label: Some("Compare Scans".to_string()),
2087        }
2088        .render()
2089        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
2090        return Html(html).into_response();
2091    };
2092
2093    let baseline_run = match read_json(base_json) {
2094        Ok(r) => r,
2095        Err(e) => {
2096            let message = if state.server_mode {
2097                "Could not load baseline scan data. The scan output folder may have been moved, \
2098                 renamed, or deleted. Re-running the analysis will create fresh comparison data."
2099                    .to_string()
2100            } else {
2101                format!(
2102                    "Could not load baseline scan data.\n\nPath: {}\n\nError: {e}\n\n\
2103                     The scan output folder may have been moved, renamed, or deleted. \
2104                     Re-running the analysis for this project will create fresh comparison data.",
2105                    base_json.display()
2106                )
2107            };
2108            let html = ErrorTemplate {
2109                message,
2110                last_report_url: Some("/compare-scans".to_string()),
2111                last_report_label: Some("Compare Scans".to_string()),
2112            }
2113            .render()
2114            .unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
2115            return (StatusCode::NOT_FOUND, Html(html)).into_response();
2116        }
2117    };
2118    let current_run = match read_json(curr_json) {
2119        Ok(r) => r,
2120        Err(e) => {
2121            let message = if state.server_mode {
2122                "Could not load current scan data. The scan output folder may have been moved, \
2123                 renamed, or deleted. Re-running the analysis will create fresh comparison data."
2124                    .to_string()
2125            } else {
2126                format!(
2127                    "Could not load current scan data.\n\nPath: {}\n\nError: {e}\n\n\
2128                     The scan output folder may have been moved, renamed, or deleted. \
2129                     Re-running the analysis for this project will create fresh comparison data.",
2130                    curr_json.display()
2131                )
2132            };
2133            let html = ErrorTemplate {
2134                message,
2135                last_report_url: Some("/compare-scans".to_string()),
2136                last_report_label: Some("Compare Scans".to_string()),
2137            }
2138            .render()
2139            .unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
2140            return (StatusCode::NOT_FOUND, Html(html)).into_response();
2141        }
2142    };
2143
2144    let comparison = compute_delta(&baseline_run, &current_run);
2145
2146    let file_rows: Vec<CompareFileDeltaRow> = comparison
2147        .file_deltas
2148        .iter()
2149        .map(|d| CompareFileDeltaRow {
2150            relative_path: d.relative_path.clone(),
2151            language: d.language.clone().unwrap_or_else(|| "—".into()),
2152            status: match d.status {
2153                FileChangeStatus::Added => "added".into(),
2154                FileChangeStatus::Removed => "removed".into(),
2155                FileChangeStatus::Modified => "modified".into(),
2156                FileChangeStatus::Unchanged => "unchanged".into(),
2157            },
2158            baseline_code: d.baseline_code,
2159            current_code: d.current_code,
2160            code_delta_str: fmt_delta(d.code_delta),
2161            code_delta_class: delta_class(d.code_delta).into(),
2162            comment_delta_str: fmt_delta(d.comment_delta),
2163            comment_delta_class: delta_class(d.comment_delta).into(),
2164            total_delta_str: fmt_delta(d.total_delta),
2165            total_delta_class: delta_class(d.total_delta).into(),
2166        })
2167        .collect();
2168
2169    let project_path = baseline_entry
2170        .input_roots
2171        .first()
2172        .map(|s| sanitize_path_str(s))
2173        .unwrap_or_default();
2174    let s = &comparison.summary;
2175    let template = CompareTemplate {
2176        baseline_run_id: baseline_entry.run_id.clone(),
2177        current_run_id: current_entry.run_id.clone(),
2178        baseline_run_id_short: baseline_entry
2179            .run_id
2180            .split('-')
2181            .next_back()
2182            .unwrap_or(&baseline_entry.run_id)
2183            .chars()
2184            .take(7)
2185            .collect(),
2186        current_run_id_short: current_entry
2187            .run_id
2188            .split('-')
2189            .next_back()
2190            .unwrap_or(&current_entry.run_id)
2191            .chars()
2192            .take(7)
2193            .collect(),
2194        baseline_timestamp: fmt_pst(baseline_entry.timestamp_utc),
2195        current_timestamp: fmt_pst(current_entry.timestamp_utc),
2196        project_path,
2197        baseline_code: s.baseline_code,
2198        current_code: s.current_code,
2199        code_lines_delta_str: fmt_delta(s.code_lines_delta),
2200        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
2201        baseline_files: s.baseline_files,
2202        current_files: s.current_files,
2203        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
2204        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
2205        baseline_comments: s.baseline_comments,
2206        current_comments: s.current_comments,
2207        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
2208        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
2209        files_added: comparison.files_added,
2210        files_removed: comparison.files_removed,
2211        files_modified: comparison.files_modified,
2212        files_unchanged: comparison.files_unchanged,
2213        file_rows,
2214        baseline_git_author: baseline_entry.git_author.clone(),
2215        current_git_author: current_entry.git_author.clone(),
2216        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
2217        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
2218        baseline_git_tags: baseline_entry.git_tags.clone(),
2219        current_git_tags: current_entry.git_tags.clone(),
2220    };
2221
2222    Html(
2223        template
2224            .render()
2225            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2226    )
2227    .into_response()
2228}
2229
2230// ── Badge endpoint ────────────────────────────────────────────────────────────
2231// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
2232// pages, Jira descriptions, etc.
2233//
2234// GET /badge/<metric>?label=<override>&color=<hex>
2235// Metrics: code-lines  files  comment-lines  blank-lines
2236
2237fn format_number(n: u64) -> String {
2238    let s = n.to_string();
2239    let mut out = String::with_capacity(s.len() + s.len() / 3);
2240    let len = s.len();
2241    for (i, c) in s.chars().enumerate() {
2242        if i > 0 && (len - i).is_multiple_of(3) {
2243            out.push(',');
2244        }
2245        out.push(c);
2246    }
2247    out
2248}
2249
2250fn badge_char_width(c: char) -> f64 {
2251    match c {
2252        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
2253        'm' | 'w' => 9.0,
2254        ' ' => 4.0,
2255        _ => 6.5,
2256    }
2257}
2258
2259fn badge_text_px(text: &str) -> u32 {
2260    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
2261}
2262
2263fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
2264    let lw = badge_text_px(label) + 20;
2265    let rw = badge_text_px(value) + 20;
2266    let total = lw + rw;
2267    let lx = lw / 2;
2268    let rx = lw + rw / 2;
2269    let le = escape_html(label);
2270    let ve = escape_html(value);
2271    let ce = escape_html(color);
2272    format!(
2273        r###"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
2274  <rect width="{total}" height="20" fill="#555"/>
2275  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
2276  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
2277    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
2278    <text x="{lx}" y="13">{le}</text>
2279    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
2280    <text x="{rx}" y="13">{ve}</text>
2281  </g>
2282</svg>"###
2283    )
2284}
2285
2286#[derive(Deserialize)]
2287struct BadgeQuery {
2288    label: Option<String>,
2289    color: Option<String>,
2290}
2291
2292async fn badge_handler(
2293    State(state): State<AppState>,
2294    AxumPath(metric): AxumPath<String>,
2295    Query(query): Query<BadgeQuery>,
2296) -> Response {
2297    let entry = {
2298        let reg = state.registry.lock().await;
2299        reg.entries.first().cloned()
2300    };
2301
2302    let Some(entry) = entry else {
2303        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
2304        return (
2305            [
2306                (header::CONTENT_TYPE, "image/svg+xml"),
2307                (header::CACHE_CONTROL, "no-cache, max-age=0"),
2308            ],
2309            svg,
2310        )
2311            .into_response();
2312    };
2313
2314    let (default_label, value, default_color) = match metric.as_str() {
2315        "code-lines" => (
2316            "code lines",
2317            format_number(entry.summary.code_lines),
2318            "#4a78ee",
2319        ),
2320        "files" => (
2321            "files analyzed",
2322            format_number(entry.summary.files_analyzed),
2323            "#4a9862",
2324        ),
2325        "comment-lines" => (
2326            "comment lines",
2327            format_number(entry.summary.comment_lines),
2328            "#b35428",
2329        ),
2330        "blank-lines" => (
2331            "blank lines",
2332            format_number(entry.summary.blank_lines),
2333            "#7a5db0",
2334        ),
2335        _ => return StatusCode::NOT_FOUND.into_response(),
2336    };
2337
2338    let label = query.label.as_deref().unwrap_or(default_label);
2339    let color = query.color.as_deref().unwrap_or(default_color);
2340    let svg = render_badge_svg(label, &value, color);
2341
2342    (
2343        [
2344            (header::CONTENT_TYPE, "image/svg+xml"),
2345            (header::CACHE_CONTROL, "no-cache, max-age=0"),
2346        ],
2347        svg,
2348    )
2349        .into_response()
2350}
2351
2352// ── Metrics API ───────────────────────────────────────────────────────────────
2353// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
2354// Confluence automation, Jira webhooks, etc.
2355//
2356// GET /api/metrics/latest
2357// GET /api/metrics/<run_id>
2358
2359#[derive(Serialize)]
2360struct ApiMetricsResponse {
2361    run_id: String,
2362    timestamp: String,
2363    project: String,
2364    summary: ApiSummaryPayload,
2365    languages: Vec<ApiLanguageRow>,
2366}
2367
2368#[derive(Serialize)]
2369struct ApiSummaryPayload {
2370    files_analyzed: u64,
2371    files_skipped: u64,
2372    code_lines: u64,
2373    comment_lines: u64,
2374    blank_lines: u64,
2375    total_physical_lines: u64,
2376    functions: u64,
2377    classes: u64,
2378    variables: u64,
2379    imports: u64,
2380}
2381
2382#[derive(Serialize)]
2383struct ApiLanguageRow {
2384    name: String,
2385    files: u64,
2386    code_lines: u64,
2387    comment_lines: u64,
2388    blank_lines: u64,
2389    functions: u64,
2390    classes: u64,
2391    variables: u64,
2392    imports: u64,
2393}
2394
2395async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
2396    let entry = {
2397        let reg = state.registry.lock().await;
2398        reg.entries.first().cloned()
2399    };
2400    match entry {
2401        Some(e) => build_metrics_response(&e),
2402        None => (
2403            StatusCode::NOT_FOUND,
2404            Json(serde_json::json!({"error": "no scans recorded yet"})),
2405        )
2406            .into_response(),
2407    }
2408}
2409
2410async fn api_metrics_run_handler(
2411    State(state): State<AppState>,
2412    AxumPath(run_id): AxumPath<String>,
2413) -> Response {
2414    let entry = {
2415        let reg = state.registry.lock().await;
2416        reg.find_by_run_id(&run_id).cloned()
2417    };
2418    match entry {
2419        Some(e) => build_metrics_response(&e),
2420        None => (
2421            StatusCode::NOT_FOUND,
2422            Json(serde_json::json!({"error": "run not found"})),
2423        )
2424            .into_response(),
2425    }
2426}
2427
2428fn build_metrics_response(entry: &RegistryEntry) -> Response {
2429    let languages: Vec<ApiLanguageRow> = entry
2430        .json_path
2431        .as_ref()
2432        .and_then(|p| read_json(p).ok())
2433        .map(|run| {
2434            run.totals_by_language
2435                .iter()
2436                .map(|l| ApiLanguageRow {
2437                    name: l.language.display_name().to_string(),
2438                    files: l.files,
2439                    code_lines: l.code_lines,
2440                    comment_lines: l.comment_lines,
2441                    blank_lines: l.blank_lines,
2442                    functions: l.functions,
2443                    classes: l.classes,
2444                    variables: l.variables,
2445                    imports: l.imports,
2446                })
2447                .collect()
2448        })
2449        .unwrap_or_default();
2450
2451    let s = &entry.summary;
2452    Json(ApiMetricsResponse {
2453        run_id: entry.run_id.clone(),
2454        timestamp: entry.timestamp_utc.to_rfc3339(),
2455        project: entry.project_label.clone(),
2456        summary: ApiSummaryPayload {
2457            files_analyzed: s.files_analyzed,
2458            files_skipped: s.files_skipped,
2459            code_lines: s.code_lines,
2460            comment_lines: s.comment_lines,
2461            blank_lines: s.blank_lines,
2462            total_physical_lines: s.total_physical_lines,
2463            functions: s.functions,
2464            classes: s.classes,
2465            variables: s.variables,
2466            imports: s.imports,
2467        },
2468        languages,
2469    })
2470    .into_response()
2471}
2472
2473// ── Project history API ───────────────────────────────────────────────────────
2474// Protected. Called by the wizard JS when the project path changes, so the UI
2475// can show a "scanned N times before" badge without a full page reload.
2476//
2477// GET /api/project-history?path=<project_root>
2478
2479#[derive(Deserialize)]
2480struct ProjectHistoryQuery {
2481    path: Option<String>,
2482}
2483
2484#[derive(Serialize)]
2485struct ProjectHistoryResponse {
2486    scan_count: usize,
2487    last_scan_id: Option<String>,
2488    last_scan_timestamp: Option<String>,
2489    last_scan_code_lines: Option<u64>,
2490    last_git_branch: Option<String>,
2491    last_git_commit: Option<String>,
2492}
2493
2494async fn project_history_handler(
2495    State(state): State<AppState>,
2496    Query(query): Query<ProjectHistoryQuery>,
2497) -> Response {
2498    let path = query.path.unwrap_or_default();
2499    let resolved = resolve_input_path(&path);
2500    let root_str = resolved.to_string_lossy().replace('\\', "/");
2501
2502    let reg = state.registry.lock().await;
2503    let entries: Vec<_> = reg
2504        .entries
2505        .iter()
2506        .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
2507        .collect();
2508
2509    let scan_count = entries.len();
2510    let last = entries.first();
2511
2512    Json(ProjectHistoryResponse {
2513        scan_count,
2514        last_scan_id: last.map(|e| e.run_id.clone()),
2515        last_scan_timestamp: last.map(|e| fmt_pst(e.timestamp_utc)),
2516        last_scan_code_lines: last.map(|e| e.summary.code_lines),
2517        last_git_branch: last.and_then(|e| e.git_branch.clone()),
2518        last_git_commit: last.and_then(|e| e.git_commit.clone()),
2519    })
2520    .into_response()
2521}
2522
2523// ── Embeddable widget ─────────────────────────────────────────────────────────
2524// Protected. Returns a self-contained HTML page suitable for iframing inside
2525// Jenkins build summaries, Confluence iframe macros, or Jira panels.
2526//
2527// GET /embed/summary?run_id=<uuid>&theme=dark
2528
2529#[derive(Deserialize)]
2530struct EmbedQuery {
2531    run_id: Option<String>,
2532    theme: Option<String>,
2533}
2534
2535async fn embed_handler(State(state): State<AppState>, Query(query): Query<EmbedQuery>) -> Response {
2536    let entry = {
2537        let reg = state.registry.lock().await;
2538        if let Some(id) = &query.run_id {
2539            reg.find_by_run_id(id).cloned()
2540        } else {
2541            reg.entries.first().cloned()
2542        }
2543    };
2544
2545    let Some(entry) = entry else {
2546        return Html(
2547            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
2548                .to_string(),
2549        )
2550        .into_response();
2551    };
2552
2553    let dark = query.theme.as_deref() == Some("dark");
2554    let languages: Vec<(String, u64, u64)> = entry
2555        .json_path
2556        .as_ref()
2557        .and_then(|p| read_json(p).ok())
2558        .map(|run| {
2559            run.totals_by_language
2560                .iter()
2561                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
2562                .collect()
2563        })
2564        .unwrap_or_default();
2565
2566    Html(render_embed_widget(&entry, &languages, dark)).into_response()
2567}
2568
2569fn render_embed_widget(
2570    entry: &RegistryEntry,
2571    languages: &[(String, u64, u64)],
2572    dark: bool,
2573) -> String {
2574    let s = &entry.summary;
2575    let total = s.code_lines + s.comment_lines + s.blank_lines;
2576    let code_pct = s
2577        .code_lines
2578        .checked_mul(100)
2579        .and_then(|n| n.checked_div(total))
2580        .unwrap_or(0);
2581
2582    let (bg, fg, surface, muted, border) = if dark {
2583        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
2584    } else {
2585        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
2586    };
2587
2588    let lang_rows: String = languages
2589        .iter()
2590        .map(|(name, files, code)| {
2591            format!(
2592                "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
2593                escape_html(name),
2594                format_number(*files),
2595                format_number(*code),
2596            )
2597        })
2598        .collect();
2599
2600    let lang_table = if lang_rows.is_empty() {
2601        String::new()
2602    } else {
2603        format!(
2604            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
2605        )
2606    };
2607
2608    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
2609    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
2610    let project_esc = escape_html(&entry.project_label);
2611    let code_lines = format_number(s.code_lines);
2612    let comment_lines = format_number(s.comment_lines);
2613    let files = format_number(s.files_analyzed);
2614    let code_raw = s.code_lines;
2615    let comment_raw = s.comment_lines;
2616    let blank_raw = s.blank_lines;
2617
2618    format!(
2619        r##"<!doctype html>
2620<html lang="en">
2621<head>
2622  <meta charset="utf-8">
2623  <meta name="viewport" content="width=device-width,initial-scale=1">
2624  <title>OxideSLOC &mdash; {project_esc}</title>
2625  <script src="/static/chart.js"></script>
2626  <style>
2627    *{{box-sizing:border-box;margin:0;padding:0}}
2628    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
2629    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
2630    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
2631    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
2632    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
2633    .card .v{{font-size:18px;font-weight:700}}
2634    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
2635    .row{{display:flex;gap:12px;align-items:flex-start}}
2636    .pie{{width:120px;height:120px;flex-shrink:0}}
2637    .lt{{border-collapse:collapse;width:100%;flex:1}}
2638    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
2639    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
2640    .n{{text-align:right}}
2641    .footer{{margin-top:10px;color:{muted};font-size:10px}}
2642  </style>
2643</head>
2644<body>
2645  <h2>{project_esc}</h2>
2646  <div class="sub">{timestamp} &middot; run {run_short}</div>
2647  <div class="cards">
2648    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
2649    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
2650    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
2651    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
2652  </div>
2653  <div class="row">
2654    <canvas class="pie" id="c"></canvas>
2655    {lang_table}
2656  </div>
2657  <div class="footer">oxide-sloc</div>
2658  <script>
2659    new Chart(document.getElementById('c'),{{
2660      type:'doughnut',
2661      data:{{
2662        labels:['Code','Comments','Blank'],
2663        datasets:[{{
2664          data:[{code_raw},{comment_raw},{blank_raw}],
2665          backgroundColor:['#4a78ee','#b35428','#aaa'],
2666          borderWidth:0
2667        }}]
2668      }},
2669      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
2670    }});
2671  </script>
2672</body>
2673</html>"##
2674    )
2675}
2676
2677fn persist_run_artifacts(
2678    run: &sloc_core::AnalysisRun,
2679    report_html: &str,
2680    run_dir: &Path,
2681    generate_json: bool,
2682    generate_html: bool,
2683    generate_pdf: bool,
2684    report_title: &str,
2685) -> Result<(RunArtifacts, PendingPdf)> {
2686    fs::create_dir_all(run_dir)
2687        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
2688
2689    let mut html_path = None;
2690    let mut pdf_path = None;
2691    let mut json_path = None;
2692    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
2693
2694    if generate_html {
2695        let path = run_dir.join("report.html");
2696        fs::write(&path, report_html)
2697            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
2698        html_path = Some(path);
2699    }
2700
2701    if generate_json {
2702        let path = run_dir.join("result.json");
2703        let json = serde_json::to_string_pretty(run)
2704            .context("failed to serialize analysis run to JSON")?;
2705        fs::write(&path, json)
2706            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
2707        json_path = Some(path);
2708    }
2709
2710    if generate_pdf {
2711        let source_html_path = if let Some(existing) = html_path.as_ref() {
2712            existing.clone()
2713        } else {
2714            let temp_html = run_dir.join("_report_rendered.html");
2715            fs::write(&temp_html, report_html).with_context(|| {
2716                format!(
2717                    "failed to write temporary HTML report to {}",
2718                    temp_html.display()
2719                )
2720            })?;
2721            temp_html
2722        };
2723
2724        let pdf_dest = run_dir.join("report.pdf");
2725        let cleanup_src = !generate_html;
2726        pdf_path = Some(pdf_dest.clone());
2727        pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
2728    }
2729
2730    Ok((
2731        RunArtifacts {
2732            output_dir: run_dir.to_path_buf(),
2733            html_path,
2734            pdf_path,
2735            json_path,
2736            report_title: report_title.to_string(),
2737        },
2738        pending_pdf,
2739    ))
2740}
2741
2742fn resolve_output_root(raw: Option<&str>) -> Result<PathBuf> {
2743    let value = raw.unwrap_or("out/web").trim();
2744    let path = if value.is_empty() {
2745        PathBuf::from("out/web")
2746    } else {
2747        PathBuf::from(value)
2748    };
2749
2750    if path.is_absolute() {
2751        Ok(path)
2752    } else {
2753        Ok(workspace_root().join(path))
2754    }
2755}
2756
2757fn split_patterns(raw: Option<&str>) -> Vec<String> {
2758    raw.unwrap_or("")
2759        .lines()
2760        .flat_map(|line| line.split(','))
2761        .map(|part| part.trim())
2762        .filter(|part| !part.is_empty())
2763        .map(ToOwned::to_owned)
2764        .collect()
2765}
2766
2767fn build_sub_run(
2768    parent: &AnalysisRun,
2769    sub: &sloc_core::SubmoduleSummary,
2770    parent_path: &str,
2771) -> AnalysisRun {
2772    let sub_files: Vec<_> = parent
2773        .per_file_records
2774        .iter()
2775        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
2776        .cloned()
2777        .collect();
2778    let mut config = parent.effective_configuration.clone();
2779    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
2780    AnalysisRun {
2781        tool: parent.tool.clone(),
2782        environment: parent.environment.clone(),
2783        effective_configuration: config,
2784        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
2785        summary_totals: SummaryTotals {
2786            files_considered: sub.files_analyzed,
2787            files_analyzed: sub.files_analyzed,
2788            files_skipped: 0,
2789            total_physical_lines: sub.total_physical_lines,
2790            code_lines: sub.code_lines,
2791            comment_lines: sub.comment_lines,
2792            blank_lines: sub.blank_lines,
2793            mixed_lines_separate: 0,
2794            functions: 0,
2795            classes: 0,
2796            variables: 0,
2797            imports: 0,
2798        },
2799        totals_by_language: sub.language_summaries.clone(),
2800        per_file_records: sub_files,
2801        skipped_file_records: vec![],
2802        warnings: vec![],
2803        submodule_summaries: vec![],
2804        git_commit_short: parent.git_commit_short.clone(),
2805        git_commit_long: parent.git_commit_long.clone(),
2806        git_branch: parent.git_branch.clone(),
2807        git_commit_author: parent.git_commit_author.clone(),
2808        git_tags: parent.git_tags.clone(),
2809    }
2810}
2811
2812fn sanitize_project_label(raw: &str) -> String {
2813    let candidate = Path::new(raw)
2814        .file_name()
2815        .and_then(|name| name.to_str())
2816        .unwrap_or("project");
2817
2818    let mut value = String::with_capacity(candidate.len());
2819    for ch in candidate.chars() {
2820        if ch.is_ascii_alphanumeric() {
2821            value.push(ch.to_ascii_lowercase());
2822        } else {
2823            value.push('-');
2824        }
2825    }
2826
2827    let compact = value.trim_matches('-').to_string();
2828    if compact.is_empty() {
2829        "project".to_string()
2830    } else {
2831        compact
2832    }
2833}
2834
2835/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
2836/// comparisons with non-canonicalized stored paths work correctly.
2837fn strip_unc_prefix(path: PathBuf) -> PathBuf {
2838    let s = path.to_string_lossy();
2839    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
2840        return PathBuf::from(format!(r"\\{rest}"));
2841    }
2842    if let Some(rest) = s.strip_prefix(r"\\?\") {
2843        return PathBuf::from(rest);
2844    }
2845    path
2846}
2847
2848fn display_path(path: &Path) -> String {
2849    let s = path.to_string_lossy();
2850    // Strip Windows extended-length prefix for display only; the underlying
2851    // PathBuf remains unchanged so file operations are unaffected.
2852    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
2853    // \\?\C:\path           →  C:\path          (local drive)
2854    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
2855        return format!(r"\\{rest}");
2856    }
2857    if let Some(rest) = s.strip_prefix(r"\\?\") {
2858        return rest.to_owned();
2859    }
2860    s.into_owned()
2861}
2862
2863fn sanitize_path_str(s: &str) -> String {
2864    // Forward-slash variants of the Windows extended-length prefix that appear
2865    // when paths stored as plain strings have been processed through some path
2866    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
2867    if let Some(rest) = s.strip_prefix("//?/UNC/") {
2868        return format!("//{rest}");
2869    }
2870    if let Some(rest) = s.strip_prefix("//?/") {
2871        return rest.to_owned();
2872    }
2873    display_path(Path::new(s))
2874}
2875
2876fn workspace_root() -> PathBuf {
2877    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
2878    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
2879        let p = PathBuf::from(root);
2880        if p.is_dir() {
2881            return p;
2882        }
2883    }
2884
2885    // Binary's parent directory — works when install.sh places the binary
2886    // next to the images/ folder, regardless of where the project lives.
2887    // This is the primary fix for cross-machine / moved-directory deployments;
2888    // env!("CARGO_MANIFEST_DIR") bakes the compile-time path into the binary,
2889    // which breaks on any machine other than the one that compiled it.
2890    if let Ok(exe) = std::env::current_exe() {
2891        if let Some(dir) = exe.parent() {
2892            if dir.join("images").is_dir() {
2893                return dir.to_path_buf();
2894            }
2895        }
2896    }
2897
2898    // Current working directory — works for `cargo run` invocations launched
2899    // from the project root, and for run.sh which cds there first.
2900    if let Ok(cwd) = std::env::current_dir() {
2901        if cwd.join("images").is_dir() {
2902            return cwd;
2903        }
2904    }
2905
2906    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2907}
2908
2909fn resolve_input_path(raw: &str) -> PathBuf {
2910    let trimmed = raw.trim();
2911    if trimmed.is_empty() {
2912        return workspace_root().join("samples").join("basic");
2913    }
2914
2915    let candidate = PathBuf::from(trimmed);
2916    let resolved = if candidate.is_absolute() {
2917        candidate
2918    } else {
2919        let rooted = workspace_root().join(&candidate);
2920        if rooted.exists() {
2921            rooted
2922        } else {
2923            workspace_root().join(candidate)
2924        }
2925    };
2926
2927    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
2928    // strip that prefix so stored paths and the displayed "Project path" are clean.
2929    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
2930    PathBuf::from(display_path(&canonical))
2931}
2932
2933fn build_preview_html(
2934    root: &Path,
2935    include_patterns: &[String],
2936    exclude_patterns: &[String],
2937) -> Result<String> {
2938    if !root.exists() {
2939        return Ok(format!(
2940            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
2941            escape_html(&display_path(root))
2942        ));
2943    }
2944
2945    let _selected = display_path(root);
2946    let mut stats = PreviewStats::default();
2947    let mut rows = Vec::new();
2948    let mut languages = Vec::new();
2949    let mut budget = PreviewBudget {
2950        shown: 0,
2951        max_entries: 600,
2952        max_depth: 9,
2953    };
2954    let mut next_row_id = 1usize;
2955
2956    let root_name = root
2957        .file_name()
2958        .and_then(|name| name.to_str())
2959        .map(|name| name.to_string())
2960        .unwrap_or_else(|| root.to_string_lossy().into_owned());
2961    let root_modified = root
2962        .metadata()
2963        .ok()
2964        .and_then(|meta| meta.modified().ok())
2965        .map(format_system_time)
2966        .unwrap_or_else(|| "-".to_string());
2967
2968    rows.push(PreviewRow {
2969        row_id: 0,
2970        parent_row_id: None,
2971        depth: 0,
2972        name: format!("{}/", root_name),
2973        kind: PreviewKind::Dir,
2974        is_dir: true,
2975        language: None,
2976        modified: root_modified,
2977        type_label: "Directory".to_string(),
2978    });
2979    collect_preview_rows(
2980        root,
2981        root,
2982        0,
2983        Some(0),
2984        &mut next_row_id,
2985        &mut budget,
2986        &mut stats,
2987        &mut rows,
2988        &mut languages,
2989        include_patterns,
2990        exclude_patterns,
2991    )?;
2992
2993    let mut out = String::new();
2994    out.push_str(r#"<div class="explorer-wrap">"#);
2995    out.push_str(r#"<div class="explorer-toolbar compact">"#);
2996    out.push_str(r#"<div class="explorer-title-group">"#);
2997    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
2998    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
2999    out.push_str(r#"</div></div>"#);
3000
3001    out.push_str(r#"<div class="scope-stats">"#);
3002    out.push_str(&format!(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));
3003    out.push_str(&format!(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));
3004    out.push_str(&format!(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));
3005    out.push_str(&format!(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));
3006    out.push_str(&format!(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));
3007    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>"#);
3008    out.push_str(r#"</div>"#);
3009
3010    out.push_str(r#"<div class="scope-info-row">"#);
3011    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
3012    if languages.is_empty() {
3013        out.push_str(
3014            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
3015        );
3016    } else {
3017        out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
3018        for language in &languages {
3019            if let Some(icon) = language_icon_file(language) {
3020                out.push_str(&format!(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)));
3021            } else if let Some(svg) = language_inline_svg(language) {
3022                out.push_str(&format!(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)));
3023            } else {
3024                out.push_str(&format!(
3025                    r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
3026                    escape_html(&language.to_ascii_lowercase()),
3027                    escape_html(language)
3028                ));
3029            }
3030        }
3031    }
3032    out.push_str(r#"</div></div>"#);
3033    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>"#);
3034    out.push_str(r#"</div>"#);
3035
3036    out.push_str(r#"<div class="file-explorer-shell">"#);
3037    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>"#);
3038    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>"#);
3039    out.push_str(r#"<div class="file-explorer-tree">"#);
3040    for row in rows {
3041        let status_label = row.kind.label();
3042        let lang_attr = row.language.unwrap_or("");
3043        let toggle_html = if row.is_dir {
3044            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
3045                .to_string()
3046        } else {
3047            r#"<span class="tree-bullet">•</span>"#.to_string()
3048        };
3049        out.push_str(&format!(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));
3050    }
3051    if budget.shown >= budget.max_entries {
3052        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>"#);
3053    }
3054    out.push_str(r#"</div></div></div>"#);
3055
3056    Ok(out)
3057}
3058
3059#[derive(Default)]
3060struct PreviewStats {
3061    directories: usize,
3062    files: usize,
3063    supported: usize,
3064    skipped: usize,
3065    unsupported: usize,
3066}
3067
3068struct PreviewRow {
3069    row_id: usize,
3070    parent_row_id: Option<usize>,
3071    depth: usize,
3072    name: String,
3073    kind: PreviewKind,
3074    is_dir: bool,
3075    language: Option<&'static str>,
3076    modified: String,
3077    type_label: String,
3078}
3079
3080#[derive(Copy, Clone)]
3081enum PreviewKind {
3082    Dir,
3083    Supported,
3084    Skipped,
3085    Unsupported,
3086}
3087
3088impl PreviewKind {
3089    fn filter_key(self) -> &'static str {
3090        match self {
3091            PreviewKind::Dir => "dir",
3092            PreviewKind::Supported => "supported",
3093            PreviewKind::Skipped => "skipped",
3094            PreviewKind::Unsupported => "unsupported",
3095        }
3096    }
3097
3098    fn label(self) -> &'static str {
3099        match self {
3100            PreviewKind::Dir => "dir",
3101            PreviewKind::Supported => "supported",
3102            PreviewKind::Skipped => "skipped by policy",
3103            PreviewKind::Unsupported => "unsupported",
3104        }
3105    }
3106
3107    fn badge_class(self) -> &'static str {
3108        match self {
3109            PreviewKind::Dir => "badge badge-dir",
3110            PreviewKind::Supported => "badge badge-scan",
3111            PreviewKind::Skipped => "badge badge-skip",
3112            PreviewKind::Unsupported => "badge badge-unsupported",
3113        }
3114    }
3115
3116    fn node_class(self) -> &'static str {
3117        match self {
3118            PreviewKind::Dir => "tree-node-dir",
3119            PreviewKind::Supported => "tree-node-supported",
3120            PreviewKind::Skipped => "tree-node-skipped",
3121            PreviewKind::Unsupported => "tree-node-unsupported",
3122        }
3123    }
3124}
3125
3126struct PreviewBudget {
3127    shown: usize,
3128    max_entries: usize,
3129    max_depth: usize,
3130}
3131
3132#[allow(clippy::too_many_arguments)]
3133fn collect_preview_rows(
3134    root: &Path,
3135    dir: &Path,
3136    depth: usize,
3137    parent_row_id: Option<usize>,
3138    next_row_id: &mut usize,
3139    budget: &mut PreviewBudget,
3140    stats: &mut PreviewStats,
3141    rows: &mut Vec<PreviewRow>,
3142    languages: &mut Vec<&'static str>,
3143    include_patterns: &[String],
3144    exclude_patterns: &[String],
3145) -> Result<()> {
3146    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
3147        return Ok(());
3148    }
3149
3150    let mut entries = fs::read_dir(dir)
3151        .with_context(|| format!("failed to read directory {}", dir.display()))?
3152        .filter_map(|entry| entry.ok())
3153        .collect::<Vec<_>>();
3154    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
3155
3156    for entry in entries {
3157        if budget.shown >= budget.max_entries {
3158            break;
3159        }
3160
3161        let path = entry.path();
3162        let name = entry.file_name().to_string_lossy().into_owned();
3163        let metadata = match entry.metadata() {
3164            Ok(meta) => meta,
3165            Err(_) => continue,
3166        };
3167        let row_id = *next_row_id;
3168        *next_row_id += 1;
3169        let modified = metadata
3170            .modified()
3171            .ok()
3172            .map(format_system_time)
3173            .unwrap_or_else(|| "-".to_string());
3174
3175        if metadata.is_dir() {
3176            let relative = preview_relative_path(root, &path);
3177            if should_skip_preview_directory(&relative, exclude_patterns) {
3178                continue;
3179            }
3180
3181            stats.directories += 1;
3182            rows.push(PreviewRow {
3183                row_id,
3184                parent_row_id,
3185                depth: depth + 1,
3186                name: format!("{}/", name),
3187                kind: PreviewKind::Dir,
3188                is_dir: true,
3189                language: None,
3190                modified,
3191                type_label: "Directory".to_string(),
3192            });
3193            budget.shown += 1;
3194            if !matches!(name.as_str(), ".git" | "node_modules" | "target") {
3195                collect_preview_rows(
3196                    root,
3197                    &path,
3198                    depth + 1,
3199                    Some(row_id),
3200                    next_row_id,
3201                    budget,
3202                    stats,
3203                    rows,
3204                    languages,
3205                    include_patterns,
3206                    exclude_patterns,
3207                )?;
3208            }
3209            continue;
3210        }
3211
3212        if metadata.is_file() {
3213            let relative = preview_relative_path(root, &path);
3214            if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
3215                continue;
3216            }
3217
3218            stats.files += 1;
3219            let kind = classify_preview_file(&name);
3220            match kind {
3221                PreviewKind::Supported => stats.supported += 1,
3222                PreviewKind::Skipped => stats.skipped += 1,
3223                PreviewKind::Unsupported => stats.unsupported += 1,
3224                PreviewKind::Dir => {}
3225            }
3226            let language = detect_language_name(&name);
3227            if let Some(language) = language {
3228                if !languages.contains(&language) {
3229                    languages.push(language);
3230                }
3231            }
3232            rows.push(PreviewRow {
3233                row_id,
3234                parent_row_id,
3235                depth: depth + 1,
3236                name: name.clone(),
3237                kind,
3238                is_dir: false,
3239                language,
3240                modified,
3241                type_label: preview_type_label(&name, language, kind),
3242            });
3243            budget.shown += 1;
3244        }
3245    }
3246
3247    Ok(())
3248}
3249
3250fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
3251    if let Some(language) = language {
3252        return format!("{} source", language);
3253    }
3254    let lower = name.to_ascii_lowercase();
3255    let ext = Path::new(&lower)
3256        .extension()
3257        .and_then(|e| e.to_str())
3258        .unwrap_or("");
3259    match kind {
3260        PreviewKind::Skipped => {
3261            if lower.ends_with(".min.js") {
3262                "Minified asset".to_string()
3263            } else if [
3264                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
3265            ]
3266            .contains(&ext)
3267            {
3268                "Binary or archive".to_string()
3269            } else {
3270                "Skipped file".to_string()
3271            }
3272        }
3273        PreviewKind::Unsupported => {
3274            if ext.is_empty() {
3275                "Unsupported file".to_string()
3276            } else {
3277                format!("{} file", ext.to_ascii_uppercase())
3278            }
3279        }
3280        PreviewKind::Supported => "Supported source".to_string(),
3281        PreviewKind::Dir => "Directory".to_string(),
3282    }
3283}
3284
3285fn format_system_time(time: SystemTime) -> String {
3286    let secs = match time.duration_since(UNIX_EPOCH) {
3287        Ok(duration) => duration.as_secs() as i64,
3288        Err(_) => return "-".to_string(),
3289    };
3290    let days = secs.div_euclid(86_400);
3291    let secs_of_day = secs.rem_euclid(86_400);
3292    let (year, month, day) = civil_from_days(days);
3293    let hour = secs_of_day / 3_600;
3294    let minute = (secs_of_day % 3_600) / 60;
3295    format!(
3296        "{:04}-{:02}-{:02} {:02}:{:02}",
3297        year, month, day, hour, minute
3298    )
3299}
3300
3301fn civil_from_days(days: i64) -> (i32, u32, u32) {
3302    let z = days + 719_468;
3303    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
3304    let doe = z - era * 146_097;
3305    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
3306    let y = yoe + era * 400;
3307    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3308    let mp = (5 * doy + 2) / 153;
3309    let d = doy - (153 * mp + 2) / 5 + 1;
3310    let m = mp + if mp < 10 { 3 } else { -9 };
3311    let year = y + if m <= 2 { 1 } else { 0 };
3312    (year as i32, m as u32, d as u32)
3313}
3314
3315fn detect_language_name(name: &str) -> Option<&'static str> {
3316    let lower = name.to_ascii_lowercase();
3317    if lower.ends_with(".c") || lower.ends_with(".h") {
3318        Some("C")
3319    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
3320        .iter()
3321        .any(|s| lower.ends_with(s))
3322    {
3323        Some("C++")
3324    } else if lower.ends_with(".cs") {
3325        Some("C#")
3326    } else if lower.ends_with(".py") {
3327        Some("Python")
3328    } else if lower.ends_with(".sh") {
3329        Some("Shell")
3330    } else if [".ps1", ".psm1", ".psd1"]
3331        .iter()
3332        .any(|s| lower.ends_with(s))
3333    {
3334        Some("PowerShell")
3335    } else {
3336        None
3337    }
3338}
3339
3340fn language_icon_file(language: &str) -> Option<&'static str> {
3341    match language {
3342        "C" => Some("c.png"),
3343        "C++" => Some("cpp.png"),
3344        "C#" => Some("c-sharp.png"),
3345        "Python" => Some("python.png"),
3346        "Shell" => Some("shell.png"),
3347        "PowerShell" => Some("powershell.png"),
3348        "JavaScript" => Some("java-script.png"),
3349        "HTML" => Some("html-5.png"),
3350        "Java" => Some("java.png"),
3351        "Visual Basic" => Some("visual-basic.png"),
3352        _ => None,
3353    }
3354}
3355
3356// Inline SVG badges for languages that have no PNG icon in images/icons/.
3357// Using inline SVG keeps the web UI fully self-contained — no extra files
3358// needed on disk, no 404s on air-gapped deployments.
3359// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
3360fn language_inline_svg(language: &str) -> Option<&'static str> {
3361    match language {
3362        "Go" => Some(
3363            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>"##,
3364        ),
3365        "Rust" => Some(
3366            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>"##,
3367        ),
3368        "TypeScript" => Some(
3369            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>"##,
3370        ),
3371        _ => None,
3372    }
3373}
3374
3375fn classify_preview_file(name: &str) -> PreviewKind {
3376    let lower = name.to_ascii_lowercase();
3377
3378    let scannable = [
3379        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
3380        ".psm1", ".psd1",
3381    ]
3382    .iter()
3383    .any(|suffix| lower.ends_with(suffix));
3384
3385    if scannable {
3386        PreviewKind::Supported
3387    } else if lower.ends_with(".min.js")
3388        || lower.ends_with(".lock")
3389        || lower.ends_with(".png")
3390        || lower.ends_with(".jpg")
3391        || lower.ends_with(".jpeg")
3392        || lower.ends_with(".gif")
3393        || lower.ends_with(".zip")
3394        || lower.ends_with(".pdf")
3395        || lower.ends_with(".pyc")
3396        || lower.ends_with(".xz")
3397        || lower.ends_with(".tar")
3398        || lower.ends_with(".gz")
3399    {
3400        PreviewKind::Skipped
3401    } else {
3402        PreviewKind::Unsupported
3403    }
3404}
3405
3406fn preview_relative_path(root: &Path, path: &Path) -> String {
3407    path.strip_prefix(root)
3408        .ok()
3409        .unwrap_or(path)
3410        .to_string_lossy()
3411        .replace('\\', "/")
3412        .trim_matches('/')
3413        .to_string()
3414}
3415
3416fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
3417    if relative.is_empty() {
3418        return false;
3419    }
3420
3421    exclude_patterns.iter().any(|pattern| {
3422        wildcard_match(pattern, relative)
3423            || wildcard_match(pattern, &format!("{relative}/"))
3424            || wildcard_match(pattern, &format!("{relative}/placeholder"))
3425    })
3426}
3427
3428fn should_include_preview_file(
3429    relative: &str,
3430    include_patterns: &[String],
3431    exclude_patterns: &[String],
3432) -> bool {
3433    if relative.is_empty() {
3434        return true;
3435    }
3436
3437    let included = include_patterns.is_empty()
3438        || include_patterns
3439            .iter()
3440            .any(|pattern| wildcard_match(pattern, relative));
3441    let excluded = exclude_patterns
3442        .iter()
3443        .any(|pattern| wildcard_match(pattern, relative));
3444
3445    included && !excluded
3446}
3447
3448fn wildcard_match(pattern: &str, candidate: &str) -> bool {
3449    let pattern = pattern.trim().replace('\\', "/");
3450    let candidate = candidate.trim().replace('\\', "/");
3451    let p = pattern.as_bytes();
3452    let c = candidate.as_bytes();
3453    let mut pi = 0usize;
3454    let mut ci = 0usize;
3455    let mut star: Option<usize> = None;
3456    let mut star_match = 0usize;
3457
3458    while ci < c.len() {
3459        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
3460            pi += 1;
3461            ci += 1;
3462        } else if pi < p.len() && p[pi] == b'*' {
3463            while pi < p.len() && p[pi] == b'*' {
3464                pi += 1;
3465            }
3466            star = Some(pi);
3467            star_match = ci;
3468        } else if let Some(star_pi) = star {
3469            star_match += 1;
3470            ci = star_match;
3471            pi = star_pi;
3472        } else {
3473            return false;
3474        }
3475    }
3476
3477    while pi < p.len() && p[pi] == b'*' {
3478        pi += 1;
3479    }
3480
3481    pi == p.len()
3482}
3483
3484fn escape_html(value: &str) -> String {
3485    value
3486        .replace('&', "&amp;")
3487        .replace('<', "&lt;")
3488        .replace('>', "&gt;")
3489        .replace('"', "&quot;")
3490        .replace('\'', "&#39;")
3491}
3492
3493#[derive(Clone)]
3494struct LanguageSummaryRow {
3495    language: String,
3496    files: u64,
3497    physical: u64,
3498    code: u64,
3499    comments: u64,
3500    blank: u64,
3501    mixed: u64,
3502    functions: u64,
3503    classes: u64,
3504    variables: u64,
3505    imports: u64,
3506}
3507
3508#[derive(Clone)]
3509struct SubmoduleRow {
3510    name: String,
3511    relative_path: String,
3512    files_analyzed: u64,
3513    code_lines: u64,
3514    comment_lines: u64,
3515    blank_lines: u64,
3516    total_physical_lines: u64,
3517    html_url: Option<String>,
3518}
3519
3520#[derive(Template)]
3521#[template(
3522    source = r##"
3523<!doctype html>
3524<html lang="en">
3525<head>
3526  <meta charset="utf-8">
3527  <title>OxideSLOC | samples/basic</title>
3528  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
3529  <style>
3530    :root {
3531      --bg: #efe9e2;
3532      --surface: #fcfaf7;
3533      --surface-2: #f7f0e8;
3534      --surface-3: #efe3d5;
3535      --line: #dfcfbf;
3536      --line-strong: #cfb29c;
3537      --text: #2f241c;
3538      --muted: #6f6257;
3539      --muted-2: #917f71;
3540      --nav: #b85d33;
3541      --nav-2: #7a371b;
3542      --accent: #2563eb;
3543      --accent-2: #1d4ed8;
3544      --oxide: #b85d33;
3545      --oxide-2: #8f4220;
3546      --success-bg: #eaf9ee;
3547      --success-text: #1c8746;
3548      --warn-bg: #fff2d8;
3549      --warn-text: #926000;
3550      --danger-bg: #fdeaea;
3551      --danger-text: #b33b3b;
3552      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
3553      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
3554      --radius: 14px;
3555    }
3556
3557    body.dark-theme {
3558      --bg: #1b1511;
3559      --surface: #261c17;
3560      --surface-2: #2d221d;
3561      --surface-3: #372922;
3562      --line: #524238;
3563      --line-strong: #6c5649;
3564      --text: #f5ece6;
3565      --muted: #c7b7aa;
3566      --muted-2: #aa9485;
3567      --nav: #b85d33;
3568      --nav-2: #7a371b;
3569      --accent: #6f9bff;
3570      --accent-2: #4a78ee;
3571      --oxide: #d37a4c;
3572      --oxide-2: #b35428;
3573      --success-bg: #163927;
3574      --success-text: #8fe2a8;
3575      --warn-bg: #3c2d11;
3576      --warn-text: #f3cb75;
3577      --danger-bg: #3d1f1f;
3578      --danger-text: #ff9f9f;
3579      --shadow: 0 14px 28px rgba(0,0,0,0.28);
3580      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
3581    }
3582
3583    * { box-sizing: border-box; }
3584    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); }
3585    html { scrollbar-gutter: stable; }
3586    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
3587    .top-nav, .page, .loading { position: relative; z-index: 2; }
3588    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
3589    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
3590    .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); }
3591    .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; }
3592    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
3593    .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)); }
3594    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
3595    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
3596    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
3597    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
3598    .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; }
3599    .nav-project-pill.visible { display:inline-flex; }
3600    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
3601    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
3602    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: wrap; }
3603    .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; }
3604    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
3605    .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; }
3606    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
3607    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
3608    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
3609    .theme-toggle .icon-sun { display:none; }
3610    body.dark-theme .theme-toggle .icon-sun { display:block; }
3611    body.dark-theme .theme-toggle .icon-moon { display:none; }
3612    .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; }
3613    .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;}
3614    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
3615    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
3616    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
3617    .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
3618    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
3619    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; }
3620    .wb-stats-header { padding: 10px 24px 0; }
3621    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
3622    .ws-left { display:flex; align-items:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
3623    .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); }
3624    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
3625    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
3626    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
3627    .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; }
3628    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
3629    .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; }
3630    .ws-badge:hover .ws-lang-tooltip { display:block; }
3631    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:9px; }
3632    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
3633    .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; }
3634    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
3635    .ws-divider { display: none; }
3636    .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%; }
3637    .ws-path-link:hover { color:var(--oxide); }
3638    body.dark-theme .ws-path-link { color:var(--oxide); }
3639    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
3640    .ws-stat-output .ws-value { overflow:hidden; display:block; }
3641    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
3642    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
3643    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
3644    .ws-mini-box-lg { flex:2 1 0; }
3645    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
3646    .ws-mini-box-br { flex:1.5 1 0; }
3647    .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); }
3648    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
3649    .path-scope-grid { display:grid; grid-template-columns: 1fr 1px auto; gap:0; align-items:stretch; }
3650    .path-scope-grid .input-group { width:100%; align-self:start; }
3651    .path-scope-sep { background:var(--line); margin:4px 14px; }
3652    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
3653    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
3654    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
3655    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
3656    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
3657    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
3658    .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; }
3659    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
3660    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
3661    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
3662    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
3663    .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; }
3664    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
3665    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
3666    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
3667    .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; }
3668    .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); }
3669    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
3670    .side-info-card { padding: 18px; }
3671    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
3672    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
3673    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
3674    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
3675    .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); }
3676    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
3677    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
3678    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
3679    .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; }
3680    .layout { display:grid; grid-template-columns: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
3681    .side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; }
3682    .step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
3683    .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); }
3684    .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; }
3685    .step-button:hover { background: var(--surface-2); }
3686    .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); }
3687    .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; }
3688    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
3689    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
3690    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
3691    .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); }
3692    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
3693    .step-nav-sum-row:last-child { border-bottom:none; }
3694    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
3695    .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; }
3696    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
3697    .quick-scan-section { padding: 10px 4px 14px; }
3698    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
3699    .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; }
3700    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
3701    .quick-scan-btn:active { transform:translateY(0); }
3702    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
3703    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
3704    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
3705    @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);} }
3706    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
3707    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
3708    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
3709    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
3710    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
3711    .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; }
3712    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
3713    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
3714    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
3715    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
3716    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
3717    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
3718    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
3719    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
3720    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
3721    .card-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
3722    .card-body { padding: 22px; }
3723    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
3724    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
3725    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
3726    .section { margin-bottom: 22px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
3727    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
3728    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
3729    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
3730    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
3731    .field { min-width:0; }
3732    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
3733    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; }
3734    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); }
3735    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
3736    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); }
3737    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
3738    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
3739    .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; }
3740    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
3741    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
3742    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
3743    .input-group.compact { grid-template-columns: 1fr auto auto; }
3744    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
3745    .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)); }
3746    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
3747    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
3748    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
3749    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
3750    .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; }
3751    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
3752    .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; }
3753    .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); }
3754    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
3755    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
3756    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
3757    button.secondary { background: var(--surface); }
3758    .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); }
3759    .section + .wizard-actions { border-top: none; padding-top: 0; }
3760    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
3761    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
3762    .field-help-grid.coupled-help { margin-top: 12px; }
3763    .field-help-grid.preset-grid { align-items: start; }
3764    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:stretch; margin-bottom: 16px; }
3765    .preset-inline-row .field { margin: 0; }
3766    .preset-inline-row .explainer-card { margin: 0; }
3767    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
3768    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
3769    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
3770    .output-field-row .field { margin: 0; }
3771    .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; }
3772    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
3773    .step3-subtitle { margin-bottom: 28px; }
3774    .counting-intro { margin-bottom: 22px; max-width: none; }
3775    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
3776    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
3777    .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; }
3778    .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; }
3779    .section-spacer-top { margin-top: 28px; }
3780    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
3781    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
3782    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
3783    .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); }
3784    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
3785    .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; }
3786    .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; }
3787    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
3788    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
3789    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
3790    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
3791    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
3792    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
3793    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
3794    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; }
3795    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
3796    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
3797    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
3798    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
3799    .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); }
3800    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
3801    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
3802    .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; }
3803    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
3804    .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; }
3805    .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; }
3806    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
3807    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
3808    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
3809    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
3810    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
3811    .advanced-rule-description strong { color: var(--text); }
3812    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
3813    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
3814    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
3815    .review-link:hover { text-decoration: underline; }
3816    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
3817    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
3818    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
3819    .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; }
3820    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
3821    .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; }
3822    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
3823    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
3824    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
3825    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
3826    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
3827    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
3828    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
3829    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
3830    .review-card ul { padding-left: 18px; margin: 0; }
3831    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
3832    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
3833    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
3834    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
3835    .review-card { min-height: 200px; }
3836    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
3837    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
3838    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
3839    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
3840    .lang-overflow-chip { position:relative; cursor:default; }
3841    .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; }
3842    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
3843    .git-inline-row { align-items:start; }
3844    .mixed-line-card { display:flex; flex-direction:column; }
3845    .preset-inline-row .toggle-card { justify-content: center; }
3846        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
3847    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
3848    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
3849    .explorer-title { font-size: 18px; font-weight: 850; }
3850    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
3851    .explorer-subtitle.wide { max-width: none; }
3852    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
3853    .better-spacing { align-items:flex-start; justify-content:flex-end; }
3854    .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; }
3855    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
3856    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
3857    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
3858    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
3859    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
3860    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
3861    .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; }
3862    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
3863    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
3864    .scope-stat-button.supported { background: var(--success-bg); }
3865    .scope-stat-button.skipped { background: var(--warn-bg); }
3866    .scope-stat-button.unsupported { background: var(--danger-bg); }
3867    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
3868    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
3869    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
3870    [data-tooltip] { position: relative; }
3871    [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); }
3872    [data-tooltip]:hover::after { display: block; }
3873    .scope-stat-button[data-tooltip] { cursor: pointer; }
3874    .badge[data-tooltip] { cursor: help; }
3875    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
3876    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
3877    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
3878    .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; }
3879    .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; }
3880    code { display:inline-block; margin-top:0; padding:2px 7px; }
3881    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
3882    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
3883    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
3884    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
3885    .language-pill.muted-pill { color: var(--muted); }
3886    button.language-pill { appearance:none; cursor:pointer; }
3887    .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); }
3888    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
3889    .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; }
3890    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
3891    .file-explorer-search-row { margin-left: auto; }
3892    .explorer-filter-select { min-width: 170px; width: 170px; }
3893    .explorer-search { min-width: 300px; width: 300px; }
3894    .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); }
3895    .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; }
3896    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
3897    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
3898    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
3899    .file-explorer-tree { max-height: 560px; overflow:auto; }
3900    .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); }
3901    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
3902    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
3903    .tree-row.hidden-by-filter { display:none !important; }
3904    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 9px 0; }
3905    .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; }
3906    .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; }
3907    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
3908    .tree-bullet { color: var(--muted-2); width: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
3909    .tree-node { display:inline-flex; align-items:center; min-width:0; }
3910    .tree-node-dir { color: var(--text); font-weight: 800; }
3911    .tree-node-supported { color: var(--success-text); }
3912    .tree-node-skipped { color: var(--warn-text); }
3913    .tree-node-unsupported { color: var(--danger-text); }
3914    .tree-node-more { color: var(--muted-2); font-style: italic; }
3915    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 13px; }
3916    .tree-status-cell { display:flex; justify-content:flex-start; }
3917    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
3918    .loading { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; background: rgba(17,24,39,0.28); z-index: 100; }
3919    .loading.active { display:flex; }
3920    .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; }
3921    .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; }
3922    @keyframes spin { to { transform: rotate(360deg);} }
3923    .progress-bar { width:100%; height:8px; margin-top:14px; background: var(--surface-3); border-radius:999px; overflow:hidden; }
3924    .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent), #6b8cff); animation: pulseBar 1.4s ease-in-out infinite; }
3925    @keyframes pulseBar { 0% { transform: translateX(-35%); width:25%; } 50% { transform: translateX(130%); width:44%; } 100% { transform: translateX(250%); width:25%; } }
3926    .hidden { display:none !important; }
3927    .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; }
3928    .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
3929    .site-footer a:hover { color: var(--text); text-decoration: underline; }
3930    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
3931    @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; } .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; } }
3932    .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;}
3933    @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));}}
3934  </style>
3935</head>
3936<body>
3937  <div class="background-watermarks" aria-hidden="true">
3938    <img src="/images/logo/logo-text.png" alt="" />
3939    <img src="/images/logo/logo-text.png" alt="" />
3940    <img src="/images/logo/logo-text.png" alt="" />
3941    <img src="/images/logo/logo-text.png" alt="" />
3942    <img src="/images/logo/logo-text.png" alt="" />
3943    <img src="/images/logo/logo-text.png" alt="" />
3944    <img src="/images/logo/logo-text.png" alt="" />
3945    <img src="/images/logo/logo-text.png" alt="" />
3946    <img src="/images/logo/logo-text.png" alt="" />
3947    <img src="/images/logo/logo-text.png" alt="" />
3948    <img src="/images/logo/logo-text.png" alt="" />
3949    <img src="/images/logo/logo-text.png" alt="" />
3950    <img src="/images/logo/logo-text.png" alt="" />
3951    <img src="/images/logo/logo-text.png" alt="" />
3952  </div>
3953  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
3954  <div class="top-nav">
3955    <div class="top-nav-inner">
3956      <a class="brand" href="/">
3957        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
3958        <div class="brand-copy">
3959          <div class="brand-title">OxideSLOC</div>
3960          <div class="brand-subtitle">Local analysis workbench</div>
3961        </div>
3962      </a>
3963      <div class="nav-project-slot">
3964        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
3965          <span class="nav-project-label">Project</span>
3966          <span class="nav-project-value" id="nav-project-title">samples/basic</span>
3967        </div>
3968      </div>
3969      <div class="nav-status">
3970        <a class="nav-pill" href="/">Home</a>
3971        <a class="nav-pill" href="/view-reports">View Reports</a>
3972        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
3973        <div class="server-status-wrap">
3974          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
3975          <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>
3976        </div>
3977        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
3978          <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>
3979          <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>
3980        </button>
3981      </div>
3982    </div>
3983  </div>
3984
3985  <div class="loading" id="loading">
3986    <div class="loading-card">
3987      <div class="spinner"></div>
3988      <h2>Scanning project...</h2>
3989      <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>
3990      <div class="progress-bar"><span></span></div>
3991    </div>
3992  </div>
3993
3994  <div class="page">
3995    <div class="workbench-strip">
3996      <div class="workbench-box wb-stats">
3997        <div class="wb-stats-header">
3998          <span class="wb-stats-title">Analysis session</span>
3999        </div>
4000        <div class="ws-left">
4001          <div class="ws-stat">
4002            <span class="ws-label">Analyzers</span>
4003            <span class="ws-value">
4004              <span class="ws-badge">41 languages
4005                <div class="ws-lang-tooltip">
4006                  <div class="ws-lang-tooltip-hdr">41 supported languages</div>
4007                  <div class="ws-lang-grid">
4008                    <span class="ws-lang-item">Assembly</span>
4009                    <span class="ws-lang-item">C</span>
4010                    <span class="ws-lang-item">C++</span>
4011                    <span class="ws-lang-item">C#</span>
4012                    <span class="ws-lang-item">Clojure</span>
4013                    <span class="ws-lang-item">CSS</span>
4014                    <span class="ws-lang-item">Dart</span>
4015                    <span class="ws-lang-item">Dockerfile</span>
4016                    <span class="ws-lang-item">Elixir</span>
4017                    <span class="ws-lang-item">Erlang</span>
4018                    <span class="ws-lang-item">F#</span>
4019                    <span class="ws-lang-item">Go</span>
4020                    <span class="ws-lang-item">Groovy</span>
4021                    <span class="ws-lang-item">Haskell</span>
4022                    <span class="ws-lang-item">HTML</span>
4023                    <span class="ws-lang-item">Java</span>
4024                    <span class="ws-lang-item">JavaScript</span>
4025                    <span class="ws-lang-item">Julia</span>
4026                    <span class="ws-lang-item">Kotlin</span>
4027                    <span class="ws-lang-item">Lua</span>
4028                    <span class="ws-lang-item">Makefile</span>
4029                    <span class="ws-lang-item">Nim</span>
4030                    <span class="ws-lang-item">Obj-C</span>
4031                    <span class="ws-lang-item">OCaml</span>
4032                    <span class="ws-lang-item">Perl</span>
4033                    <span class="ws-lang-item">PHP</span>
4034                    <span class="ws-lang-item">PowerShell</span>
4035                    <span class="ws-lang-item">Python</span>
4036                    <span class="ws-lang-item">R</span>
4037                    <span class="ws-lang-item">Ruby</span>
4038                    <span class="ws-lang-item">Rust</span>
4039                    <span class="ws-lang-item">Scala</span>
4040                    <span class="ws-lang-item">SCSS</span>
4041                    <span class="ws-lang-item">Shell</span>
4042                    <span class="ws-lang-item">SQL</span>
4043                    <span class="ws-lang-item">Svelte</span>
4044                    <span class="ws-lang-item">Swift</span>
4045                    <span class="ws-lang-item">TypeScript</span>
4046                    <span class="ws-lang-item">Vue</span>
4047                    <span class="ws-lang-item">XML</span>
4048                    <span class="ws-lang-item">Zig</span>
4049                  </div>
4050                </div>
4051              </span>
4052            </span>
4053          </div>
4054          <div class="ws-divider"></div>
4055          <div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
4056          <div class="ws-divider"></div>
4057          <div class="ws-stat"><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
4058          <div class="ws-divider"></div>
4059          <div class="ws-stat ws-stat-output">
4060            <span class="ws-label">Output</span>
4061            <span class="ws-value">
4062              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
4063                <span id="ws-output-root">project/sloc</span>
4064              </button>
4065            </span>
4066          </div>
4067        </div>
4068      </div>
4069      <div class="workbench-box ws-history-group">
4070        <div class="ws-history-label">Scan history</div>
4071        <div class="ws-history-inner">
4072          <div class="ws-mini-box ws-mini-box-sm">
4073            <div class="ws-mini-label">Scans</div>
4074            <div class="ws-mini-value" id="ws-scan-count">—</div>
4075          </div>
4076          <div class="ws-mini-box ws-mini-box-lg">
4077            <div class="ws-mini-label">Last Scan</div>
4078            <div class="ws-mini-value" id="ws-last-scan">—</div>
4079          </div>
4080          <div class="ws-mini-box ws-mini-box-br">
4081            <div class="ws-mini-label">Branch</div>
4082            <div class="ws-mini-value" id="ws-branch">—</div>
4083          </div>
4084        </div>
4085      </div>
4086    </div>
4087
4088    <div class="layout">
4089      <aside class="side-stack">
4090        <section class="step-nav">
4091        <h3>Guided scan setup</h3>
4092        <button type="button" class="step-button active" data-step-target="1"><span class="step-num">1</span><span>Select project</span></button>
4093        <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
4094        <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
4095        <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
4096
4097        <div class="step-nav-info" id="step-nav-info">
4098          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
4099          <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>
4100        </div>
4101
4102        <div class="step-nav-summary" id="step-nav-summary" style="display:none;">
4103          <div class="step-nav-sum-row"><span class="step-nav-sum-key">Path</span><span class="step-nav-sum-val" id="snav-path">—</span></div>
4104          <div class="step-nav-sum-row"><span class="step-nav-sum-key">Output</span><span class="step-nav-sum-val" id="snav-output">—</span></div>
4105          <div class="step-nav-sum-row"><span class="step-nav-sum-key">Title</span><span class="step-nav-sum-val" id="snav-title">—</span></div>
4106        </div>
4107
4108        <div class="quick-scan-divider"></div>
4109        <div class="quick-scan-section">
4110          <div class="quick-scan-label">No customization needed?</div>
4111          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
4112            <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>
4113            Quick Scan
4114          </button>
4115          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
4116        </div>
4117        </section>
4118
4119      </aside>
4120
4121      <section class="card">
4122        <div class="card-header">
4123          <div class="card-title-row">
4124            <div>
4125              <h1 class="card-title">Guided scan configuration</h1>
4126              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
4127            </div>
4128            <div class="wizard-progress" aria-label="Scan setup progress">
4129              <div class="wizard-progress-top">
4130                <span class="wizard-progress-label">Setup progress</span>
4131                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
4132              </div>
4133              <div class="wizard-progress-track">
4134                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
4135              </div>
4136            </div>
4137          </div>
4138        </div>
4139        <div class="card-body">
4140          <form method="post" action="/analyze" id="analyze-form">
4141            <div class="wizard-step active" data-step="1">
4142              <div class="section">
4143                <div class="section-kicker">Step 1</div>
4144                <h2>Select project and preview scope</h2>
4145                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
4146                <div class="field" style="margin:10px 0 0;">
4147                  <label for="path">Project path</label>
4148                  <div class="path-scope-grid">
4149                    <div class="input-group">
4150                      <input id="path" name="path" type="text" value="samples/basic" placeholder="/path/to/repository" required />
4151                      <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
4152                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
4153                    </div>
4154                    <div class="path-scope-sep"></div>
4155                    <div class="scope-legend-row">
4156                      <span class="scope-legend-label">Scope legend:</span>
4157                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
4158                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
4159                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
4160                    </div>
4161                  </div>
4162                  <div class="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</div>
4163                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
4164                </div>
4165
4166                <div style="height:1px;background:var(--line);margin:28px 0;"></div>
4167
4168                <div id="preview-panel" style="margin-top:0;">
4169                  <div class="preview-error">Loading preview...</div>
4170                </div>
4171              </div>
4172
4173              <div class="section">
4174                <div class="field-grid">
4175                  <div class="field">
4176                    <label for="include_globs">Include globs</label>
4177                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
4178                    <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>
4179                  </div>
4180                  <div class="field">
4181                    <label for="exclude_globs">Exclude globs</label>
4182                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
4183                    <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>
4184                  </div>
4185                </div>
4186                <div class="glob-guidance-grid">
4187                  <div class="glob-guidance-card">
4188                    <strong>How to read them</strong>
4189                    <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>
4190                  </div>
4191                  <div class="glob-guidance-card">
4192                    <strong>Common include examples</strong>
4193                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
4194                  </div>
4195                  <div class="glob-guidance-card">
4196                    <strong>Common exclude examples</strong>
4197                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
4198                  </div>
4199                </div>
4200              </div>
4201
4202              <div class="section" style="margin-top:14px;">
4203                <div class="preset-inline-row git-inline-row">
4204                  <div class="toggle-card" style="margin:0;">
4205                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
4206                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
4207                    <label class="checkbox">
4208                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
4209                      <div>
4210                        <span>Detect and separate git submodules</span>
4211                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
4212                      </div>
4213                    </label>
4214                  </div>
4215                  <div class="explainer-card prominent" style="margin:0;">
4216                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
4217                    <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>
4218                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
4219    path = libs/core
4220    url  = https://github.com/org/core.git
4221
4222[submodule "libs/ui"]
4223    path = libs/ui
4224    url  = https://github.com/org/ui.git</div>
4225                  </div>
4226                </div>
4227              </div>
4228
4229              <div class="wizard-actions">
4230                <div class="left"></div>
4231                <div class="right">
4232                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
4233                </div>
4234              </div>
4235            </div>
4236
4237            <div class="wizard-step" data-step="2">
4238              <div class="section">
4239                <div class="section-kicker">Step 2</div>
4240                <h2>Choose counting behavior</h2>
4241                <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>
4242                <div class="subsection-bar">Primary line classification</div>
4243                <div class="preset-inline-row" style="align-items:start;">
4244                  <div class="toggle-card mixed-line-card" style="margin:0;">
4245                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
4246                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
4247                    <select id="mixed_line_policy" name="mixed_line_policy">
4248                      <option value="code_only">Code only</option>
4249                      <option value="code_and_comment">Code and comment</option>
4250                      <option value="comment_only">Comment only</option>
4251                      <option value="separate_mixed_category">Separate mixed category</option>
4252                    </select>
4253                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
4254                  </div>
4255                  <div class="explainer-card prominent" style="margin:0;">
4256                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
4257                    <div class="explainer-body" id="mixed-policy-description"></div>
4258                    <div class="code-sample" id="mixed-policy-example"></div>
4259                  </div>
4260                </div>
4261              </div>
4262
4263              <div class="subsection-bar">Additional scan rules</div>
4264              <div class="scan-rules-grid">
4265                <div class="preset-inline-row">
4266                  <div class="toggle-card" style="margin:0;">
4267                    <div class="field-help-title">Generated files</div>
4268                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
4269                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4270                  </div>
4271                  <div class="explainer-card prominent" style="margin:0;">
4272                    <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>
4273                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
4274# Files matching codegen patterns are excluded:
4275#   *.generated.cs  *.pb.go  *.g.dart</div>
4276                  </div>
4277                </div>
4278                <div class="preset-inline-row">
4279                  <div class="toggle-card" style="margin:0;">
4280                    <div class="field-help-title">Minified files</div>
4281                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
4282                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4283                  </div>
4284                  <div class="explainer-card prominent" style="margin:0;">
4285                    <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>
4286                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
4287# Heuristic: very long lines + low whitespace ratio
4288#   jquery.min.js  bundle.min.css  → skipped</div>
4289                  </div>
4290                </div>
4291                <div class="preset-inline-row">
4292                  <div class="toggle-card" style="margin:0;">
4293                    <div class="field-help-title">Vendor directories</div>
4294                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
4295                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
4296                  </div>
4297                  <div class="explainer-card prominent" style="margin:0;">
4298                    <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>
4299                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
4300# Directories named vendor/ node_modules/ third_party/
4301#   → entire subtree is excluded from totals</div>
4302                  </div>
4303                </div>
4304                <div class="preset-inline-row">
4305                  <div class="toggle-card" style="margin:0;">
4306                    <div class="field-help-title">Lockfiles and manifests</div>
4307                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
4308                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
4309                  </div>
4310                  <div class="explainer-card prominent" style="margin:0;">
4311                    <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>
4312                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
4313# Files like package-lock.json  Cargo.lock  yarn.lock
4314#   → skipped unless this is enabled</div>
4315                  </div>
4316                </div>
4317                <div class="preset-inline-row">
4318                  <div class="toggle-card" style="margin:0;">
4319                    <div class="field-help-title">Binary handling</div>
4320                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
4321                    <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>
4322                  </div>
4323                  <div class="explainer-card prominent" style="margin:0;">
4324                    <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>
4325                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
4326# Detected via long lines + low whitespace heuristic
4327#   .png  .exe  .so  → skipped silently</div>
4328                  </div>
4329                </div>
4330                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
4331                  <div class="toggle-card" style="margin:0;">
4332                    <div class="field-help-title">Python docstrings</div>
4333                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
4334                    <label class="checkbox">
4335                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
4336                      <span>Count as comment-style lines</span>
4337                    </label>
4338                  </div>
4339                  <div class="explainer-card prominent" style="margin:0;">
4340                    <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>
4341                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
4342                  </div>
4343                </div>
4344              </div>
4345              <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
4346                  <div class="always-tracked-tip">
4347                    <div class="always-tracked-tip-icon">ℹ</div>
4348                    <div class="always-tracked-tip-body">
4349                      <div class="field-help-title">Always tracked — not configurable</div>
4350                      <h4>Comment and blank-line basics</h4>
4351                      <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>
4352                    </div>
4353                  </div>
4354                  <div class="always-tracked-tip">
4355                    <div class="always-tracked-tip-icon">→</div>
4356                    <div class="always-tracked-tip-body">
4357                      <div class="field-help-title">What these settings change</div>
4358                      <h4>Lines on the boundary</h4>
4359                      <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>
4360                    </div>
4361                  </div>
4362                </div>
4363
4364              <div class="wizard-actions">
4365                <div class="left">
4366                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
4367                </div>
4368                <div class="right">
4369                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
4370                </div>
4371              </div>
4372            </div>
4373
4374            <div class="wizard-step" data-step="3">
4375              <div class="section">
4376                <div class="section-kicker">Step 3</div>
4377                <h2>Output and report identity</h2>
4378                <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>
4379                <div class="preset-inline-row" style="align-items:start;">
4380                  <div class="toggle-card" style="margin:0;">
4381                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
4382                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
4383                    <select id="scan_preset">
4384                      <option value="balanced">Balanced local scan</option>
4385                      <option value="code_focused">Code focused</option>
4386                      <option value="comment_audit">Comment audit</option>
4387                      <option value="deep_review">Deep review</option>
4388                    </select>
4389                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
4390                  </div>
4391                  <div class="explainer-card">
4392                    <div class="field-help-title">Selected scan preset</div>
4393                    <div class="explainer-body" id="scan-preset-description"></div>
4394                    <div class="preset-summary-row" id="scan-preset-summary"></div>
4395                    <div class="code-sample" id="scan-preset-example"></div>
4396                    <div class="preset-note" id="scan-preset-note"></div>
4397                  </div>
4398                </div>
4399                <hr class="step3-separator" />
4400                <div class="preset-inline-row" style="align-items:start;">
4401                  <div class="toggle-card" style="margin:0;">
4402                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
4403                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
4404                    <select id="artifact_preset">
4405                      <option value="review">Review bundle</option>
4406                      <option value="full">Full bundle</option>
4407                      <option value="html_only">HTML only</option>
4408                      <option value="machine">Machine bundle</option>
4409                    </select>
4410                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
4411                  </div>
4412                  <div class="explainer-card">
4413                    <div class="field-help-title">Selected artifact preset</div>
4414                    <div class="explainer-body" id="artifact-preset-description"></div>
4415                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
4416                    <div class="code-sample" id="artifact-preset-example"></div>
4417                  </div>
4418                </div>
4419              </div>
4420
4421              <div class="section section-spacer-top">
4422                <div class="output-field-row">
4423                  <div class="field">
4424                    <label for="output_dir">Output directory</label>
4425                    <div class="input-group compact">
4426                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
4427                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
4428                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
4429                    </div>
4430                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
4431                  </div>
4432                  <div class="output-field-aside">
4433                    <strong>Where reports land</strong>
4434                    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.
4435                  </div>
4436                </div>
4437              </div>
4438
4439              <div class="section section-spacer-top">
4440                <div class="output-field-row">
4441                  <div class="field">
4442                    <label for="report_title">Report title</label>
4443                    <input id="report_title" name="report_title" type="text" value="samples/basic" placeholder="Project report title" />
4444                    <div class="hint">Appears in HTML and PDF output headers.</div>
4445                  </div>
4446                  <div class="output-field-aside">
4447                    <strong>Shown in exported artifacts</strong>
4448                    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.
4449                  </div>
4450                </div>
4451              </div>
4452
4453              <div class="section">
4454                <div class="section-kicker">Artifacts</div>
4455                <div class="artifact-grid">
4456                  <div class="artifact-card selected" data-artifact="html">
4457                    <div class="marker">✓</div>
4458                    <div class="artifact-icon">H</div>
4459                    <h4>HTML report</h4>
4460                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
4461                    <div class="artifact-tags">
4462                      <span class="soft-chip">Best for visual review</span>
4463                      <span class="soft-chip">Embeddable preview</span>
4464                    </div>
4465                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
4466                  </div>
4467                  <div class="artifact-card selected" data-artifact="pdf">
4468                    <div class="marker">✓</div>
4469                    <div class="artifact-icon">P</div>
4470                    <h4>PDF export</h4>
4471                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
4472                    <div class="artifact-tags">
4473                      <span class="soft-chip">Portable snapshot</span>
4474                      <span class="soft-chip">Good for handoff</span>
4475                    </div>
4476                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
4477                  </div>
4478                  <div class="artifact-card selected" data-artifact="json" style="opacity:0.75;pointer-events:none;">
4479                    <div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
4480                    <div class="artifact-icon">J</div>
4481                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">Always on</span></h4>
4482                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
4483                    <div class="artifact-tags">
4484                      <span class="soft-chip">Required for compare</span>
4485                      <span class="soft-chip">Auto-enabled</span>
4486                    </div>
4487                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
4488                  </div>
4489                </div>
4490                <div class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</div>
4491              </div>
4492
4493              <div class="wizard-actions">
4494                <div class="left">
4495                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
4496                </div>
4497                <div class="right">
4498                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
4499                </div>
4500              </div>
4501            </div>
4502
4503            <div class="wizard-step" data-step="4">
4504              <div class="section">
4505                <div class="section-kicker">Step 4</div>
4506                <h2>Review selections and run</h2>
4507                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
4508                <div class="review-grid">
4509                  <div class="review-card highlight">
4510                    <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>
4511                    <ul id="review-scan-summary"></ul>
4512                  </div>
4513                  <div class="review-card highlight">
4514                    <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>
4515                    <ul id="review-count-summary"></ul>
4516                  </div>
4517                  <div class="review-card">
4518                    <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>
4519                    <ul id="review-artifact-summary"></ul>
4520                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
4521                  </div>
4522                  <div class="review-card">
4523                    <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>
4524                    <ul id="review-preview-summary"></ul>
4525                  </div>
4526                </div>
4527              </div>
4528
4529              <div class="wizard-actions">
4530                <div class="left">
4531                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
4532                </div>
4533                <div class="right">
4534                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
4535                </div>
4536              </div>
4537            </div></form>
4538        </div>
4539      </section>
4540    </div>
4541  </div>
4542
4543  <script>
4544    (function () {
4545      var form = document.getElementById("analyze-form");
4546      var loading = document.getElementById("loading");
4547      var submitButton = document.getElementById("submit-button");
4548      var pathInput = document.getElementById("path");
4549      var outputDirInput = document.getElementById("output_dir");
4550      var reportTitleInput = document.getElementById("report_title");
4551      var previewPanel = document.getElementById("preview-panel");
4552      var refreshButton = document.getElementById("refresh-preview");
4553      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
4554      var useSamplePath = document.getElementById("use-sample-path");
4555      var useDefaultOutput = document.getElementById("use-default-output");
4556      var browsePath = document.getElementById("browse-path");
4557      var browseOutputDir = document.getElementById("browse-output-dir");
4558      var themeToggle = document.getElementById("theme-toggle");
4559      var mixedLinePolicy = document.getElementById("mixed_line_policy");
4560      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
4561      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
4562      var scanPreset = document.getElementById("scan_preset");
4563      var artifactPreset = document.getElementById("artifact_preset");
4564      var includeGlobsInput = document.getElementById("include_globs");
4565      var excludeGlobsInput = document.getElementById("exclude_globs");
4566      var liveReportTitle = document.getElementById("live-report-title");
4567      var navProjectPill = document.getElementById("nav-project-pill");
4568      var navProjectTitle = document.getElementById("nav-project-title");
4569      var reportTitlePreview = null;
4570      var wizardProgressFill = document.getElementById("wizard-progress-fill");
4571      var wizardProgressValue = document.getElementById("wizard-progress-value");
4572      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
4573      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
4574      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
4575      var reportTitleTouched = false;
4576      var currentStep = 1;
4577      var previewTimer = null;
4578      var quickScanBtn = document.getElementById("quick-scan-btn");
4579
4580      if (quickScanBtn) {
4581        quickScanBtn.addEventListener("click", function () {
4582          var pathVal = pathInput ? pathInput.value.trim() : "";
4583          if (!pathVal) {
4584            alert("Please enter or browse to a project path first.");
4585            return;
4586          }
4587          quickScanBtn.disabled = true;
4588          quickScanBtn.textContent = "Scanning...";
4589          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
4590          if (loading) loading.classList.add("active");
4591          if (form) form.submit();
4592        });
4593      }
4594
4595      var mixedPolicyInfo = {
4596        code_only: {
4597          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.",
4598          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'
4599        },
4600        code_and_comment: {
4601          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.",
4602          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'
4603        },
4604        comment_only: {
4605          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.",
4606          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'
4607        },
4608        separate_mixed_category: {
4609          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.",
4610          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'
4611        }
4612      };
4613
4614      var scanPresetInfo = {
4615        balanced: {
4616          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.",
4617          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
4618          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
4619          note: "Best when you want a stable local overview before making deeper adjustments.",
4620          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4621        },
4622        code_focused: {
4623          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
4624          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
4625          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
4626          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
4627          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4628        },
4629        comment_audit: {
4630          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
4631          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
4632          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
4633          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
4634          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
4635        },
4636        deep_review: {
4637          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
4638          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
4639          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
4640          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
4641          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
4642        }
4643      };
4644
4645      var artifactPresetInfo = {
4646        review: {
4647          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.",
4648          chips: ["HTML", "PDF"],
4649          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
4650        },
4651        full: {
4652          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.",
4653          chips: ["HTML", "PDF", "JSON"],
4654          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
4655        },
4656        html_only: {
4657          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.",
4658          chips: ["HTML only", "Fast local review"],
4659          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
4660        },
4661        machine: {
4662          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
4663          chips: ["HTML", "JSON"],
4664          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
4665        }
4666      };
4667
4668      function applyTheme(theme) {
4669        if (theme === "dark") document.body.classList.add("dark-theme");
4670        else document.body.classList.remove("dark-theme");
4671      }
4672
4673      function loadSavedTheme() {
4674        var saved = null;
4675        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
4676        applyTheme(saved === "dark" ? "dark" : "light");
4677      }
4678
4679      function updateScrollProgress() {
4680        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
4681        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
4682        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
4683        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
4684        var step = Math.min(Math.max(currentStep, 1), 4);
4685        var base = stepBase[step];
4686        var end  = stepEnd[step];
4687
4688        var scrollFrac = 0;
4689        var activePanel = document.querySelector(".wizard-step.active");
4690        if (activePanel) {
4691          var scrollTop = window.scrollY || window.pageYOffset || 0;
4692          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
4693          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
4694          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
4695          var scrolled = scrollTop + viewH - panelTop;
4696          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
4697        }
4698
4699        var percent = Math.round(base + (end - base) * scrollFrac);
4700        percent = Math.min(end, Math.max(base, percent));
4701        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
4702        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
4703      }
4704
4705      function updateWizardProgress() {
4706        updateScrollProgress();
4707      }
4708
4709      var stepDescriptions = [
4710        "Choose a project folder, apply scope filters, and preview which files will be counted.",
4711        "Configure how mixed code-plus-comment lines and docstrings are classified.",
4712        "Pick your output formats, scan preset, and where reports are saved.",
4713        "Review all settings and launch the analysis."
4714      ];
4715
4716      function updateStepNav(step) {
4717        var infoLabel = document.getElementById("step-nav-info-label");
4718        var infoDesc  = document.getElementById("step-nav-info-desc");
4719        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
4720        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
4721
4722        var summary = document.getElementById("step-nav-summary");
4723        if (summary) summary.style.display = step > 1 ? "" : "none";
4724
4725        var snavPath   = document.getElementById("snav-path");
4726        var snavOutput = document.getElementById("snav-output");
4727        var snavTitle  = document.getElementById("snav-title");
4728        var pv = pathInput ? pathInput.value.trim() : "";
4729        var ov = outputDirInput ? outputDirInput.value.trim() : "";
4730        var tv = reportTitleInput ? reportTitleInput.value.trim() : "";
4731        if (snavPath)   snavPath.textContent   = pv  || "—";
4732        if (snavOutput) snavOutput.textContent  = ov  || "auto";
4733        if (snavTitle)  snavTitle.textContent   = tv  || "—";
4734      }
4735
4736      function setStep(step, pushHistory) {
4737        currentStep = step;
4738        stepPanels.forEach(function (panel) {
4739          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
4740        });
4741        stepButtons.forEach(function (button) {
4742          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
4743        });
4744        updateWizardProgress();
4745        updateStepNav(step);
4746
4747        if (pushHistory !== false) {
4748          try {
4749            history.pushState({ wizardStep: step }, "", "#step" + step);
4750          } catch (e) {}
4751        }
4752
4753        var wizardTop =
4754          document.querySelector(".page-shell") ||
4755          document.querySelector(".page") ||
4756          document.querySelector(".card") ||
4757          document.body;
4758
4759        var top = 0;
4760        try {
4761          top = Math.max(0, wizardTop.getBoundingClientRect().top + window.scrollY - 16);
4762        } catch (e) {
4763          top = 0;
4764        }
4765
4766        window.scrollTo({ top: top, behavior: "smooth" });
4767      }
4768
4769      window.addEventListener("popstate", function (e) {
4770        if (e.state && e.state.wizardStep) {
4771          setStep(e.state.wizardStep, false);
4772        } else {
4773          var hashMatch = location.hash.match(/^#step([1-4])$/);
4774          if (hashMatch) setStep(Number(hashMatch[1]), false);
4775        }
4776      });
4777
4778      function inferTitleFromPath(value) {
4779        if (!value) return "project";
4780        var cleaned = value.replace(/[\/\\]+$/, "");
4781        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
4782        return parts.length ? parts[parts.length - 1] : value;
4783      }
4784
4785      function updateReportTitleFromPath() {
4786        var inferred = inferTitleFromPath(pathInput.value || "samples/basic");
4787        if (!reportTitleTouched) {
4788          reportTitleInput.value = inferred;
4789        }
4790        var title = reportTitleInput.value || inferred;
4791        if (liveReportTitle) liveReportTitle.textContent = title;
4792        if (reportTitlePreview) reportTitlePreview.textContent = title;
4793        document.title = "OxideSLOC | " + title;
4794
4795        var projectPath = (pathInput.value || "").trim();
4796        if (navProjectPill && navProjectTitle) {
4797          if (projectPath.length > 0) {
4798            navProjectTitle.textContent = inferred;
4799            navProjectPill.classList.add("visible");
4800          } else {
4801            navProjectTitle.textContent = "";
4802            navProjectPill.classList.remove("visible");
4803          }
4804        }
4805      }
4806
4807      function updateMixedPolicyUI() {
4808        var key = mixedLinePolicy.value || "code_only";
4809        var info = mixedPolicyInfo[key];
4810        document.getElementById("mixed-policy-description").textContent = info.description;
4811        document.getElementById("mixed-policy-example").textContent = info.example;
4812      }
4813
4814      function updatePythonDocstringUI() {
4815        var checked = !!pythonDocstrings.checked;
4816        document.getElementById("python-docstring-example").textContent = checked
4817          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
4818          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
4819        document.getElementById("python-docstring-live-help").textContent = checked
4820          ? "Enabled: docstrings contribute to comment-style totals."
4821          : "Disabled: docstrings are not counted as comment content.";
4822      }
4823
4824      function renderPresetChips(targetId, chips) {
4825        var target = document.getElementById(targetId);
4826        if (!target) return;
4827        target.innerHTML = (chips || []).map(function (chip) {
4828          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
4829        }).join('');
4830      }
4831
4832      function updatePresetDescriptions() {
4833        var scanInfo = scanPresetInfo[scanPreset.value];
4834        var artifactInfo = artifactPresetInfo[artifactPreset.value];
4835        document.getElementById("scan-preset-description").textContent = scanInfo.description;
4836        document.getElementById("scan-preset-example").textContent = scanInfo.example;
4837        document.getElementById("scan-preset-note").textContent = scanInfo.note;
4838        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
4839        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
4840        renderPresetChips("scan-preset-summary", scanInfo.chips);
4841        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
4842      }
4843
4844      function applyScanPreset() {
4845        var info = scanPresetInfo[scanPreset.value];
4846        if (!info || !info.apply) return;
4847        mixedLinePolicy.value = info.apply.mixed;
4848        pythonDocstrings.checked = !!info.apply.docstrings;
4849        document.getElementById("generated_file_detection").value = info.apply.generated;
4850        document.getElementById("minified_file_detection").value = info.apply.minified;
4851        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
4852        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
4853        document.getElementById("binary_file_behavior").value = info.apply.binary;
4854        updateMixedPolicyUI();
4855        updatePythonDocstringUI();
4856      }
4857
4858      function applyArtifactPreset() {
4859        var enabled = { html: false, pdf: false, json: false };
4860        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
4861        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
4862        if (artifactPreset.value === "html_only") { enabled.html = true; }
4863        if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
4864
4865        artifactCards.forEach(function (card) {
4866          var artifact = card.getAttribute("data-artifact");
4867          var checked = !!enabled[artifact];
4868          var checkbox = card.querySelector(".artifact-checkbox");
4869          checkbox.checked = checked;
4870          card.classList.toggle("selected", checked);
4871        });
4872      }
4873
4874      function toggleArtifactCard(card) {
4875        var checkbox = card.querySelector(".artifact-checkbox");
4876        checkbox.checked = !checkbox.checked;
4877        card.classList.toggle("selected", checkbox.checked);
4878      }
4879
4880      function updateReview() {
4881        var scanSummary = document.getElementById("review-scan-summary");
4882        var countSummary = document.getElementById("review-count-summary");
4883        var artifactSummary = document.getElementById("review-artifact-summary");
4884        var outputSummary = document.getElementById("review-output-summary");
4885        var previewSummary = document.getElementById("review-preview-summary");
4886        var readinessSummary = document.getElementById("review-readiness-summary");
4887        var includeText = document.getElementById("include_globs").value.trim();
4888        var excludeText = document.getElementById("exclude_globs").value.trim();
4889        var sidePathPreview = document.getElementById("side-path-preview");
4890        var sideOutputPreview = document.getElementById("side-output-preview");
4891        var sideTitlePreview = document.getElementById("side-title-preview");
4892
4893        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "samples/basic"; }
4894        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
4895        if (sideTitlePreview) {
4896          var rt = document.getElementById("report_title");
4897          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
4898        }
4899
4900        scanSummary.innerHTML = ""
4901          + "<li>Path: " + escapeHtml(pathInput.value || "samples/basic") + "</li>"
4902          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
4903          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
4904
4905        countSummary.innerHTML = ""
4906          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
4907          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
4908          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
4909          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
4910          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
4911          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
4912          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
4913          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
4914
4915        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.querySelector("h4").textContent; });
4916        artifactSummary.innerHTML = ""
4917          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
4918          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
4919
4920        outputSummary.innerHTML = ""
4921          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
4922          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value || "samples/basic")) + "</li>";
4923
4924        if (previewSummary) {
4925          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
4926          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
4927          var statMap = {};
4928          statButtons.forEach(function (button) {
4929            var valueNode = button.querySelector('.scope-stat-value');
4930            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
4931          });
4932          previewSummary.innerHTML = ''
4933            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
4934            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
4935            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
4936            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
4937            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
4938            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
4939
4940          if (readinessSummary) {
4941            var selectedArtifactsCount = selectedArtifacts.length;
4942            readinessSummary.innerHTML = ''
4943              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
4944              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
4945              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
4946              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
4947          }
4948        }
4949      }
4950
4951      function escapeHtml(value) {
4952        return String(value)
4953          .replace(/&/g, "&amp;")
4954          .replace(/</g, "&lt;")
4955          .replace(/>/g, "&gt;")
4956          .replace(/"/g, "&quot;")
4957          .replace(/'/g, "&#39;");
4958      }
4959
4960      function isPythonVisible() {
4961        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
4962      }
4963
4964      function syncPythonVisibility() {
4965        var html = previewPanel.textContent || "";
4966        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
4967        pythonWraps.forEach(function (node) {
4968          node.classList.toggle("hidden", !hasPython);
4969        });
4970      }
4971
4972      function attachPreviewInteractions() {
4973        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
4974        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
4975        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
4976        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
4977        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
4978        var searchInput = previewPanel.querySelector("#explorer-search");
4979        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
4980        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
4981        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
4982        var activeFilter = "all";
4983        var activeLanguage = "";
4984        var searchTerm = "";
4985        var currentSortKey = null;
4986        var currentSortOrder = "asc";
4987        var childRows = {};
4988
4989        rows.forEach(function (row) {
4990          var parentId = row.getAttribute("data-parent-id") || "";
4991          var rowId = row.getAttribute("data-row-id") || "";
4992          if (!childRows[parentId]) childRows[parentId] = [];
4993          childRows[parentId].push(rowId);
4994        });
4995
4996        function rowById(id) {
4997          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
4998        }
4999
5000        function hasCollapsedAncestor(row) {
5001          var parentId = row.getAttribute("data-parent-id");
5002          while (parentId) {
5003            var parent = rowById(parentId);
5004            if (!parent) break;
5005            if (parent.getAttribute("data-expanded") === "false") return true;
5006            parentId = parent.getAttribute("data-parent-id");
5007          }
5008          return false;
5009        }
5010
5011        function updateToggleGlyph(row) {
5012          var toggle = row.querySelector(".tree-toggle");
5013          if (!toggle) return;
5014          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
5015        }
5016
5017        function rowSortValue(row, key) {
5018          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
5019        }
5020
5021        function updateSortButtons() {
5022          sortButtons.forEach(function (button) {
5023            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
5024            var indicator = button.querySelector(".tree-sort-indicator");
5025            button.classList.toggle("active", isActive);
5026            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
5027            if (indicator) {
5028              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
5029            }
5030          });
5031        }
5032
5033        function sortSiblingRows() {
5034          if (!treeContainer) {
5035            updateSortButtons();
5036            return;
5037          }
5038
5039          var rowMap = {};
5040          var childrenMap = {};
5041          rows.forEach(function (row) {
5042            var rowId = row.getAttribute("data-row-id");
5043            var parentId = row.getAttribute("data-parent-id") || "";
5044            rowMap[rowId] = row;
5045            if (!childrenMap[parentId]) childrenMap[parentId] = [];
5046            childrenMap[parentId].push(rowId);
5047          });
5048
5049          Object.keys(childrenMap).forEach(function (parentId) {
5050            if (!parentId) return;
5051            childrenMap[parentId].sort(function (a, b) {
5052              var rowA = rowMap[a];
5053              var rowB = rowMap[b];
5054              if (!currentSortKey) {
5055                return Number(a) - Number(b);
5056              }
5057              var valueA = rowSortValue(rowA, currentSortKey);
5058              var valueB = rowSortValue(rowB, currentSortKey);
5059              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
5060              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
5061              var fallbackA = rowSortValue(rowA, "name");
5062              var fallbackB = rowSortValue(rowB, "name");
5063              if (fallbackA < fallbackB) return -1;
5064              if (fallbackA > fallbackB) return 1;
5065              return Number(a) - Number(b);
5066            });
5067          });
5068
5069          var orderedIds = [];
5070          function pushChildren(parentId) {
5071            (childrenMap[parentId] || []).forEach(function (childId) {
5072              orderedIds.push(childId);
5073              pushChildren(childId);
5074            });
5075          }
5076
5077          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
5078            orderedIds.push(topId);
5079            pushChildren(topId);
5080          });
5081
5082          orderedIds.forEach(function (id) {
5083            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
5084          });
5085          updateSortButtons();
5086        }
5087
5088        function updateLanguageButtons() {
5089          languageButtons.forEach(function (button) {
5090            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
5091            var isActive = languageValue === activeLanguage;
5092            button.classList.toggle("active", isActive);
5093          });
5094        }
5095
5096        function rowSelfMatches(row) {
5097          var kind = row.getAttribute("data-kind");
5098          var status = row.getAttribute("data-status");
5099          var language = (row.getAttribute("data-language") || "").toLowerCase();
5100          var name = row.getAttribute("data-name-lower") || "";
5101          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
5102          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
5103          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
5104          var passesLanguage = !activeLanguage || language === activeLanguage;
5105          return passesFilter && passesSearch && passesLanguage;
5106        }
5107
5108        function hasMatchingDescendant(rowId) {
5109          return (childRows[rowId] || []).some(function (childId) {
5110            var childRow = rowById(childId);
5111            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
5112          });
5113        }
5114
5115        function rowMatches(row) {
5116          if (rowSelfMatches(row)) return true;
5117          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
5118        }
5119
5120        function resetViewState() {
5121          activeFilter = "all";
5122          activeLanguage = "";
5123          searchTerm = "";
5124          currentSortKey = null;
5125          currentSortOrder = "asc";
5126          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5127          if (searchInput) searchInput.value = "";
5128          if (filterSelect) filterSelect.value = "all";
5129          updateLanguageButtons();
5130        }
5131
5132        function applyVisibility() {
5133          rows.forEach(function (row) {
5134            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
5135            row.classList.toggle("hidden-by-filter", !visible);
5136            row.style.display = visible ? "grid" : "none";
5137          });
5138          buttons.forEach(function (button) {
5139            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
5140          });
5141          if (filterSelect) filterSelect.value = activeFilter;
5142        }
5143
5144        buttons.forEach(function (button) {
5145          button.addEventListener("click", function () {
5146            var filterValue = button.getAttribute("data-filter") || "all";
5147            if (filterValue === "reset-view") {
5148              resetViewState();
5149              sortSiblingRows();
5150              applyVisibility();
5151              return;
5152            }
5153            activeFilter = filterValue;
5154            applyVisibility();
5155          });
5156        });
5157
5158        rows.forEach(function (row) {
5159          updateToggleGlyph(row);
5160          var toggle = row.querySelector(".tree-toggle");
5161          if (toggle) {
5162            toggle.addEventListener("click", function () {
5163              var expanded = row.getAttribute("data-expanded") !== "false";
5164              row.setAttribute("data-expanded", expanded ? "false" : "true");
5165              updateToggleGlyph(row);
5166              applyVisibility();
5167            });
5168          }
5169        });
5170
5171        actionButtons.forEach(function (button) {
5172          button.addEventListener("click", function () {
5173            var action = button.getAttribute("data-explorer-action");
5174            if (action === "expand-all") {
5175              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
5176            } else if (action === "collapse-all") {
5177              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
5178            } else if (action === "clear-filters") {
5179              resetViewState();
5180            }
5181            sortSiblingRows();
5182            applyVisibility();
5183          });
5184        });
5185
5186        if (filterSelect) {
5187          filterSelect.addEventListener("change", function () {
5188            activeFilter = filterSelect.value || "all";
5189            applyVisibility();
5190          });
5191        }
5192
5193        languageButtons.forEach(function (button) {
5194          button.addEventListener("click", function () {
5195            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
5196            updateLanguageButtons();
5197            applyVisibility();
5198          });
5199        });
5200
5201        sortButtons.forEach(function (button) {
5202          button.addEventListener("click", function () {
5203            var sortKey = button.getAttribute("data-sort-key");
5204            if (currentSortKey === sortKey) {
5205              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
5206            } else {
5207              currentSortKey = sortKey;
5208              currentSortOrder = "asc";
5209            }
5210            sortSiblingRows();
5211            applyVisibility();
5212          });
5213        });
5214
5215        if (searchInput) {
5216          searchInput.addEventListener("input", function () {
5217            searchTerm = searchInput.value.trim().toLowerCase();
5218            applyVisibility();
5219          });
5220        }
5221
5222        updateLanguageButtons();
5223        sortSiblingRows();
5224        applyVisibility();
5225      }
5226
5227      function loadPreview() {
5228        if (!previewPanel || !pathInput) return;
5229        var path = pathInput.value || "samples/basic";
5230        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
5231        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
5232        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
5233        var previewUrl = "/preview?path=" + encodeURIComponent(path)
5234          + "&include_globs=" + encodeURIComponent(includeValue)
5235          + "&exclude_globs=" + encodeURIComponent(excludeValue);
5236        fetch(previewUrl)
5237          .then(function (response) { return response.text(); })
5238          .then(function (html) {
5239            previewPanel.innerHTML = html;
5240            attachPreviewInteractions();
5241            syncPythonVisibility();
5242            updateReview();
5243            setTimeout(collapseLanguagePills, 50);
5244          })
5245          .catch(function (err) {
5246            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
5247          });
5248      }
5249
5250      function pickDirectory(targetInput, kind) {
5251        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
5252        if (browseButton) browseButton.disabled = true;
5253
5254        if (previewPanel && targetInput === pathInput) {
5255          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
5256        }
5257
5258        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
5259          .then(function (response) { return response.json(); })
5260          .then(function (data) {
5261            if (data && data.selected_path) {
5262              targetInput.value = data.selected_path;
5263
5264              if (targetInput === pathInput) {
5265                updateReportTitleFromPath();
5266                autoSetOutputDir(data.selected_path);
5267                fetchProjectHistory(data.selected_path);
5268                loadPreview();
5269              }
5270
5271              updateReview();
5272            } else if (targetInput === pathInput) {
5273              // Cancelled — keep existing value and refresh preview with current path
5274              loadPreview();
5275            }
5276          })
5277          .catch(function () {
5278            window.alert("Directory picker request failed.");
5279            if (previewPanel && targetInput === pathInput) {
5280              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
5281            }
5282          })
5283          .finally(function () {
5284            if (browseButton) browseButton.disabled = false;
5285          });
5286      }
5287
5288      if (themeToggle) {
5289        themeToggle.addEventListener("click", function () {
5290          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
5291          applyTheme(nextTheme);
5292          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
5293        });
5294      }
5295
5296      stepButtons.forEach(function (button) {
5297        button.addEventListener("click", function () {
5298          setStep(Number(button.getAttribute("data-step-target")));
5299        });
5300      });
5301
5302      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
5303        button.addEventListener("click", function () {
5304          setStep(Number(button.getAttribute("data-step-target")) || 1);
5305        });
5306      });
5307
5308      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
5309        button.addEventListener("click", function () {
5310          updateReview();
5311          setStep(Number(button.getAttribute("data-next")));
5312        });
5313      });
5314
5315      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
5316        button.addEventListener("click", function () {
5317          setStep(Number(button.getAttribute("data-prev")));
5318        });
5319      });
5320
5321      if (useSamplePath) {
5322        useSamplePath.addEventListener("click", function () {
5323          pathInput.value = "samples/basic";
5324          updateReportTitleFromPath();
5325          loadPreview();
5326        });
5327      }
5328
5329      if (useDefaultOutput) {
5330        useDefaultOutput.addEventListener("click", function () {
5331          delete outputDirInput.dataset.userEdited;
5332          autoSetOutputDir(pathInput ? pathInput.value : "");
5333          updateReview();
5334        });
5335      }
5336
5337      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
5338      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
5339
5340      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
5341
5342      // ── Language pill overflow: collapse to "+N more" chip ─────────────
5343      function collapseLanguagePills() {
5344        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
5345        rows.forEach(function(row) {
5346          // Remove any previous overflow chip
5347          var prev = row.querySelector('.lang-overflow-chip');
5348          if (prev) prev.remove();
5349          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
5350          pills.forEach(function(p) { p.style.display = ''; });
5351          if (!pills.length) return;
5352
5353          // Measure after restoring all pills
5354          var containerRight = row.getBoundingClientRect().right;
5355          var hidden = [];
5356          for (var i = pills.length - 1; i >= 1; i--) {
5357            var rect = pills[i].getBoundingClientRect();
5358            if (rect.right > containerRight + 2) {
5359              hidden.unshift(pills[i]);
5360              pills[i].style.display = 'none';
5361            } else {
5362              break;
5363            }
5364          }
5365
5366          if (hidden.length) {
5367            var chip = document.createElement('button');
5368            chip.type = 'button';
5369            chip.className = 'language-pill lang-overflow-chip';
5370            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
5371            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
5372            row.appendChild(chip);
5373          }
5374        });
5375      }
5376
5377      // Run after preview loads (preview panel populates language pills)
5378      var _origLoadPreviewCb = window.__previewLoaded;
5379      document.addEventListener('previewLoaded', collapseLanguagePills);
5380      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
5381      setTimeout(collapseLanguagePills, 400);
5382
5383      // ── Project history & output dir auto-set ──────────────────────────
5384      var wsOutputRoot   = document.getElementById("ws-output-root");
5385      var wsScanCount    = document.getElementById("ws-scan-count");
5386      var wsLastScan     = document.getElementById("ws-last-scan");
5387      var historyBadge   = document.getElementById("path-history-badge");
5388      var historyTimer   = null;
5389
5390      var wsOutputLink = document.getElementById("ws-output-link");
5391      function syncStripOutputRoot() {
5392        var val = outputDirInput ? outputDirInput.value : "";
5393        var display = val || "project/sloc";
5394        if (wsOutputRoot) wsOutputRoot.textContent = display;
5395        if (wsOutputLink) wsOutputLink.dataset.folder = val;
5396      }
5397
5398      function autoSetOutputDir(projectPath) {
5399        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
5400        if (!projectPath || !projectPath.trim()) return;
5401        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
5402        outputDirInput.value = cleaned + "/sloc";
5403        syncStripOutputRoot();
5404        updateReview();
5405      }
5406
5407      var wsBranch = document.getElementById("ws-branch");
5408
5409      function fetchProjectHistory(projectPath) {
5410        if (!projectPath || !projectPath.trim()) {
5411          if (wsScanCount) wsScanCount.textContent = "—";
5412          if (wsLastScan)  wsLastScan.textContent  = "—";
5413          if (wsBranch)    wsBranch.textContent    = "—";
5414          if (historyBadge) historyBadge.style.display = "none";
5415          return;
5416        }
5417        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
5418          .then(function (r) { return r.ok ? r.json() : null; })
5419          .then(function (data) {
5420            if (!data) return;
5421            var countStr = data.scan_count > 0
5422              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
5423              : "never";
5424            var tsStr = data.last_scan_timestamp
5425              ? data.last_scan_timestamp.replace(" UTC","")
5426              : "—";
5427            if (wsScanCount) wsScanCount.textContent = countStr;
5428            if (wsLastScan)  wsLastScan.textContent  = tsStr;
5429            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
5430            if (data.scan_count > 0) {
5431              if (historyBadge) {
5432                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
5433                historyBadge.textContent = data.scan_count + " previous scan" +
5434                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
5435                  "Last: " + (data.last_scan_timestamp || "—") +
5436                  " — " + (data.last_scan_code_lines ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " code lines.";
5437                historyBadge.className = "path-history-badge found";
5438                historyBadge.style.display = "";
5439              }
5440            } else {
5441              if (historyBadge) historyBadge.style.display = "none";
5442            }
5443          })
5444          .catch(function () {});
5445      }
5446
5447      function onPathChange() {
5448        var val = pathInput ? pathInput.value : "";
5449        updateReportTitleFromPath();
5450        autoSetOutputDir(val);
5451        clearTimeout(historyTimer);
5452        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
5453        if (previewTimer) clearTimeout(previewTimer);
5454        previewTimer = setTimeout(loadPreview, 280);
5455      }
5456
5457      if (pathInput) {
5458        pathInput.addEventListener("input", onPathChange);
5459      }
5460
5461      if (outputDirInput) {
5462        outputDirInput.addEventListener("input", function () {
5463          outputDirInput.dataset.userEdited = "1";
5464          syncStripOutputRoot();
5465          updateReview();
5466        });
5467      }
5468
5469      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
5470        if (!node) return;
5471        node.addEventListener("input", function () {
5472          updateReview();
5473          if (previewTimer) clearTimeout(previewTimer);
5474          previewTimer = setTimeout(loadPreview, 280);
5475        });
5476      });
5477
5478      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
5479        var node = document.getElementById(id);
5480        if (node) node.addEventListener("change", updateReview);
5481      });
5482
5483      if (reportTitleInput) {
5484        reportTitleInput.addEventListener("input", function () {
5485          reportTitleTouched = reportTitleInput.value.trim().length > 0;
5486          updateReportTitleFromPath();
5487          updateReview();
5488        });
5489      }
5490
5491      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
5492      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
5493      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); });
5494      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
5495
5496      artifactCards.forEach(function (card) {
5497        card.addEventListener("click", function () {
5498          toggleArtifactCard(card);
5499          updateReview();
5500        });
5501      });
5502
5503      if (form && loading && submitButton) {
5504        form.addEventListener("submit", function () {
5505          submitButton.disabled = true;
5506          submitButton.textContent = "Scanning...";
5507          loading.classList.add("active");
5508        });
5509      }
5510
5511      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
5512        btn.addEventListener('click', function () {
5513          var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
5514          if (!folder) return;
5515          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
5516        });
5517      });
5518
5519      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
5520      if (wsOutputLink) {
5521        wsOutputLink.addEventListener('click', function () {
5522          var folder = wsOutputLink.dataset.folder || '';
5523          if (!folder) return;
5524          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
5525        });
5526      }
5527
5528      loadSavedTheme();
5529      updateMixedPolicyUI();
5530      updatePythonDocstringUI();
5531      applyScanPreset();
5532      updatePresetDescriptions();
5533      applyArtifactPreset();
5534      updateReview();
5535      updateScrollProgress(); // initialise bar to 0% (step 1)
5536      window.addEventListener("scroll", updateScrollProgress, { passive: true });
5537      onPathChange();         // seed output dir, history badge, and preview from initial path
5538      loadPreview();
5539      updateStepNav(1);
5540
5541      // Restore step from URL hash on initial load (e.g., back-forward cache)
5542      (function() {
5543        var hashMatch = location.hash.match(/^#step([1-4])$/);
5544        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
5545      })();
5546
5547      (function randomizeWatermarks() {
5548        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
5549        if (!wms.length) return;
5550        var placed = [];
5551        function tooClose(top, left) {
5552          for (var i = 0; i < placed.length; i++) {
5553            var dt = Math.abs(placed[i][0] - top);
5554            var dl = Math.abs(placed[i][1] - left);
5555            if (dt < 16 && dl < 12) return true;
5556          }
5557          return false;
5558        }
5559        function pick(leftBand) {
5560          for (var attempt = 0; attempt < 50; attempt++) {
5561            var top = Math.random() * 88 + 2;
5562            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5563            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
5564          }
5565          var top = Math.random() * 88 + 2;
5566          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5567          placed.push([top, left]);
5568          return [top, left];
5569        }
5570        var half = Math.floor(wms.length / 2);
5571        wms.forEach(function (img, i) {
5572          var pos = pick(i < half);
5573          var size = Math.floor(Math.random() * 80 + 110);
5574          var rot = (Math.random() * 360).toFixed(1);
5575          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
5576          img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot + "deg);opacity:" + op + ";";
5577        });
5578      })();
5579
5580      (function spawnCodeParticles() {
5581        var container = document.getElementById('code-particles');
5582        if (!container) return;
5583        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'];
5584        for (var i = 0; i < 38; i++) {
5585          (function(idx) {
5586            var el = document.createElement('span');
5587            el.className = 'code-particle';
5588            el.textContent = snippets[idx % snippets.length];
5589            var left = Math.random() * 94 + 2;
5590            var top = Math.random() * 88 + 6;
5591            var dur = (Math.random() * 10 + 9).toFixed(1);
5592            var delay = (Math.random() * 18).toFixed(1);
5593            var rot = (Math.random() * 26 - 13).toFixed(1);
5594            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
5595            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
5596            container.appendChild(el);
5597          })(i);
5598        }
5599      })();
5600    })();
5601  </script>
5602  <script>
5603    (function () {
5604      var raw = {{ prefill_json|safe }};
5605      if (!raw || typeof raw !== 'object' || !raw.path) return;
5606      function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
5607      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
5608      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
5609      setVal('path-input', raw.path || '');
5610      setVal('include-globs', raw.include_globs || '');
5611      setVal('exclude-globs', raw.exclude_globs || '');
5612      setVal('output-dir', raw.output_dir || '');
5613      setVal('report-title', raw.report_title || '');
5614      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
5615      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
5616      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
5617      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
5618      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
5619      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
5620      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
5621      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
5622      setChecked('generate-html', raw.generate_html !== false);
5623      setChecked('generate-pdf', !!raw.generate_pdf);
5624      // Trigger dynamic UI updates after pre-fill.
5625      setTimeout(function () {
5626        var pathEl = document.getElementById('path-input');
5627        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
5628        var policyEl = document.getElementById('mixed-line-policy');
5629        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
5630      }, 80);
5631    })();
5632  </script>
5633  <footer class="site-footer">
5634    oxide-sloc v{{ version }} — local source line analysis workbench &nbsp;·&nbsp;
5635    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
5636    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
5637    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
5638  </footer>
5639</body>
5640</html>
5641"##,
5642    ext = "html"
5643)]
5644struct IndexTemplate {
5645    version: &'static str,
5646    prefill_json: String,
5647}
5648
5649// ── SplashTemplate ────────────────────────────────────────────────────────────
5650
5651#[derive(Template)]
5652#[template(
5653    source = r##"
5654<!doctype html>
5655<html lang="en">
5656<head>
5657  <meta charset="utf-8">
5658  <meta name="viewport" content="width=device-width, initial-scale=1">
5659  <title>OxideSLOC — Source Line Analysis Workbench</title>
5660  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5661  <style>
5662    :root {
5663      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
5664      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5665      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
5666      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5667      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
5668    }
5669    body.dark-theme {
5670      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
5671      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
5672    }
5673    *{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);}
5674    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
5675    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
5676    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
5677    .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;}
5678    @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));}}
5679    .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);}
5680    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
5681    .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));}
5682    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
5683    .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;}
5684    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
5685    .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;}
5686    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
5687    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
5688    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
5689    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
5690    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
5691    .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;}
5692    .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;}
5693    .page{max-width:1100px;margin:0 auto;padding:48px 24px 60px;position:relative;z-index:1;}
5694    .hero{text-align:center;margin-bottom:52px;}
5695    .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;}
5696    @keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
5697    .hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
5698      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
5699      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
5700      animation:titleShimmer 4s linear infinite;}
5701    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
5702    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;}
5703    .hero-subtitle{font-size:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
5704    @keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
5705    .action-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:32px;}
5706    @media(max-width:760px){.action-grid{grid-template-columns:1fr 1fr;}}
5707    @media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
5708    .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;}
5709    .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;}
5710    @keyframes cardRise{from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:translateY(0);}}
5711    .action-card:hover{transform:translateY(-6px) scale(1.012);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
5712    .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);}
5713    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
5714    .action-card-icon svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;}
5715    .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);}
5716    .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);}
5717    .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);}
5718    .action-card-title{font-size:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
5719    .action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
5720    .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;}
5721    body.dark-theme .action-card-cta{color:var(--oxide);}
5722    .action-card.view .action-card-cta{color:var(--accent-2);}
5723    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
5724    .action-card.compare .action-card-cta{color:#7c3aed;}
5725    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
5726    .action-card:hover .action-card-cta{gap:12px;}
5727    .divider{height:1px;background:var(--line);margin:40px 0;}
5728    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
5729    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
5730    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
5731    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;text-align:center;position:relative;cursor:default;
5732      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
5733    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
5734    .info-chip-val{font-size:22px;font-weight:900;color:var(--oxide);}
5735    body.dark-theme .info-chip-val{color:var(--oxide);}
5736    .info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
5737    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
5738      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
5739      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
5740    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
5741      border:6px solid transparent;border-top-color:var(--text);}
5742    .info-chip:hover .info-chip-tip{display:block;}
5743    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
5744    .site-footer a{color:var(--muted);}
5745  </style>
5746</head>
5747<body>
5748  <div class="background-watermarks" aria-hidden="true">
5749    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5750    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5751    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5752    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5753    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5754    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5755    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5756  </div>
5757  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5758  <div class="top-nav">
5759    <div class="top-nav-inner">
5760      <a class="brand" href="/">
5761        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5762        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
5763      </a>
5764      <div class="nav-right">
5765        <a class="nav-pill" href="/">Home</a>
5766        <a class="nav-pill" href="/view-reports">View Reports</a>
5767        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5768        <div class="server-status-wrap">
5769          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
5770          <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>
5771        </div>
5772        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5773          <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>
5774          <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>
5775        </button>
5776      </div>
5777    </div>
5778  </div>
5779
5780  <div class="page">
5781    <div class="hero">
5782      <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
5783      <h1 class="hero-title">OxideSLOC</h1>
5784      <p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
5785    </div>
5786
5787    <div class="action-grid">
5788      <a class="action-card scan" href="/scan-setup">
5789        <div class="action-card-icon">
5790          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
5791        </div>
5792        <div class="action-card-title">Scan Project</div>
5793        <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>
5794        <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>
5795      </a>
5796
5797      <a class="action-card view" href="/view-reports">
5798        <div class="action-card-icon">
5799          <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
5800        </div>
5801        <div class="action-card-title">View Reports</div>
5802        <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>
5803        <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>
5804      </a>
5805
5806      <a class="action-card compare" href="/compare-scans">
5807        <div class="action-card-icon">
5808          <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>
5809        </div>
5810        <div class="action-card-title">Compare Scans</div>
5811        <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>
5812        <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>
5813      </a>
5814    </div>
5815
5816    <div class="divider"></div>
5817
5818    <div class="info-strip">
5819      <div class="info-chip">
5820        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
5821        <div class="info-chip-val">41</div>
5822        <div class="info-chip-label">Languages</div>
5823      </div>
5824      <div class="info-chip">
5825        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
5826        <div class="info-chip-val">100%</div>
5827        <div class="info-chip-label">Self-contained</div>
5828      </div>
5829      <div class="info-chip">
5830        <div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
5831        <div class="info-chip-val">HTML</div>
5832        <div class="info-chip-label">Exportable reports</div>
5833      </div>
5834      <div class="info-chip">
5835        <div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
5836        <div class="info-chip-val">Git</div>
5837        <div class="info-chip-label">Submodule support</div>
5838      </div>
5839      <div class="info-chip">
5840        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
5841        <div class="info-chip-val">IEEE</div>
5842        <div class="info-chip-label">1045-1992</div>
5843      </div>
5844    </div>
5845  </div>
5846
5847  <footer class="site-footer">
5848    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
5849    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
5850    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
5851    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
5852  </footer>
5853
5854  <script>
5855    (function () {
5856      var storageKey = 'oxide-sloc-theme';
5857      var body = document.body;
5858      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
5859      var toggle = document.getElementById('theme-toggle');
5860      if (toggle) toggle.addEventListener('click', function () {
5861        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
5862        body.classList.toggle('dark-theme', next === 'dark');
5863        try { localStorage.setItem(storageKey, next); } catch(e) {}
5864      });
5865      (function randomizeWatermarks() {
5866        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
5867        if (!wms.length) return;
5868        var placed = [];
5869        function tooClose(top, left) {
5870          for (var i = 0; i < placed.length; i++) {
5871            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
5872            if (dt < 16 && dl < 12) return true;
5873          }
5874          return false;
5875        }
5876        function pick(leftBand) {
5877          for (var attempt = 0; attempt < 50; attempt++) {
5878            var top = Math.random() * 88 + 2;
5879            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5880            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
5881          }
5882          var top = Math.random() * 88 + 2;
5883          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
5884          placed.push([top, left]); return [top, left];
5885        }
5886        var half = Math.floor(wms.length / 2);
5887        wms.forEach(function (img, i) {
5888          var pos = pick(i < half);
5889          var size = Math.floor(Math.random() * 100 + 120);
5890          var rot = (Math.random() * 360).toFixed(1);
5891          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
5892          img.style.cssText = 'width:' + size + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
5893        });
5894      })();
5895
5896      (function spawnCodeParticles() {
5897        var container = document.getElementById('code-particles');
5898        if (!container) return;
5899        var snippets = [
5900          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
5901          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
5902          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
5903          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
5904          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
5905        ];
5906        var count = 38;
5907        for (var i = 0; i < count; i++) {
5908          (function(idx) {
5909            var el = document.createElement('span');
5910            el.className = 'code-particle';
5911            var text = snippets[idx % snippets.length];
5912            el.textContent = text;
5913            var left = Math.random() * 94 + 2;
5914            var top = Math.random() * 88 + 6;
5915            var dur = (Math.random() * 10 + 9).toFixed(1);
5916            var delay = (Math.random() * 18).toFixed(1);
5917            var rot = (Math.random() * 26 - 13).toFixed(1);
5918            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
5919            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;'
5920              + '--rot:' + rot + 'deg;--op:' + op + ';'
5921              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
5922            container.appendChild(el);
5923          })(i);
5924        }
5925      })();
5926    })();
5927  </script>
5928</body>
5929</html>
5930"##,
5931    ext = "html"
5932)]
5933struct SplashTemplate {}
5934
5935// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
5936
5937#[derive(Template)]
5938#[template(
5939    source = r##"
5940<!doctype html>
5941<html lang="en">
5942<head>
5943  <meta charset="utf-8">
5944  <meta name="viewport" content="width=device-width, initial-scale=1">
5945  <title>OxideSLOC — Start a Scan</title>
5946  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5947  <style>
5948    :root {
5949      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
5950      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5951      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
5952      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5953      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
5954    }
5955    body.dark-theme {
5956      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
5957      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
5958    }
5959    *{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);}
5960    .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);}
5961    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
5962    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
5963    .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));}
5964    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
5965    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
5966    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;}
5967    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
5968    .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;}
5969    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
5970    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
5971    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
5972    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
5973    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
5974    .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
5975    .page-header{text-align:center;margin-bottom:32px;}
5976    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
5977    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
5978    .breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
5979    .breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
5980    .breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
5981    /* Cards */
5982    .option-grid{display:flex;flex-direction:column;gap:16px;}
5983    .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;}
5984    .option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
5985    /* Two-column layout inside each card */
5986    .card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
5987    .card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
5988    .option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
5989    .option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
5990    .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
5991    .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
5992    .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
5993    .card-text{min-width:0;}
5994    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
5995    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
5996    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
5997    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
5998    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
5999    /* Right CTA column */
6000    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
6001    .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;}
6002    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
6003    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
6004    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
6005    body.dark-theme .btn-secondary{color:var(--oxide);}
6006    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
6007    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
6008    /* File input overlay — must be full-width so it aligns with other card-right buttons */
6009    .file-input-wrap{position:relative;width:100%;}
6010    .file-input-wrap .btn{width:100%;}
6011    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
6012    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6013    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
6014    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
6015    .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;}
6016    @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));}}
6017    /* Recent list (card 3 — full-width section below header) */
6018    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
6019    .recent-list{display:flex;flex-direction:column;gap:8px;}
6020    .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;}
6021    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
6022    .recent-item-info{flex:1;min-width:0;}
6023    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
6024    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
6025    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
6026    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
6027    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
6028    .site-footer a{color:var(--muted);}
6029    @media(max-width:680px){
6030      .card-body{grid-template-columns:1fr;}
6031      .card-right{flex-direction:row;flex-wrap:wrap;}
6032      .btn{flex:1;}
6033    }
6034  </style>
6035</head>
6036<body>
6037  <div class="background-watermarks" aria-hidden="true">
6038    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6039    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6040    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6041    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6042    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6043    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6044    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
6045  </div>
6046  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6047  <div class="top-nav">
6048    <div class="top-nav-inner">
6049      <a class="brand" href="/">
6050        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
6051        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Source line analysis workbench</div></div>
6052      </a>
6053      <div class="nav-right">
6054        <a class="nav-pill" href="/">Home</a>
6055        <a class="nav-pill" href="/view-reports">View Reports</a>
6056        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
6057        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
6058          <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>
6059          <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>
6060        </button>
6061      </div>
6062    </div>
6063  </div>
6064
6065  <div class="page">
6066    <div class="breadcrumb">
6067      <a href="/">Home</a>
6068      <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6069      <span>Scan Setup</span>
6070    </div>
6071
6072    <div class="page-header">
6073      <h1>How would you like to scan?</h1>
6074      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
6075    </div>
6076
6077    <div class="option-grid">
6078
6079      <!-- Option 1: New scan -->
6080      <div class="option-card">
6081        <div class="card-body">
6082          <div class="card-left">
6083            <div class="option-icon new-scan">
6084              <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
6085            </div>
6086            <div class="card-text">
6087              <div class="option-title">Start a new scan</div>
6088              <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>
6089              <ul class="feature-list">
6090                <li>Live project scope preview before you run</li>
6091                <li>4 line-counting modes with interactive examples</li>
6092                <li>HTML, PDF, and JSON output — your choice</li>
6093                <li>IEEE 1045-1992 compliant physical SLOC counting</li>
6094              </ul>
6095            </div>
6096          </div>
6097          <div class="card-right">
6098            <a class="btn btn-primary" href="/scan">
6099              Configure &amp; scan
6100              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
6101            </a>
6102            <p class="card-tip">Full 4-step setup · all options</p>
6103          </div>
6104        </div>
6105      </div>
6106
6107      <!-- Option 2: Load from config file -->
6108      <div class="option-card">
6109        <div class="card-body">
6110          <div class="card-left">
6111            <div class="option-icon load-config">
6112              <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>
6113            </div>
6114            <div class="card-text">
6115              <div class="option-title">Load a saved config</div>
6116              <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>
6117              <ul class="feature-list">
6118                <li>All 15 settings restored from the file</li>
6119                <li>Fully editable — change path or output dir</li>
6120                <li>Works with any scan-config.json</li>
6121              </ul>
6122            </div>
6123          </div>
6124          <div class="card-right">
6125            <div class="file-input-wrap">
6126              <button class="btn btn-secondary" id="load-config-btn" type="button">
6127                <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>
6128                Choose config file
6129              </button>
6130              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
6131            </div>
6132            <p class="card-tip" id="config-file-name">Exported after every scan</p>
6133          </div>
6134        </div>
6135      </div>
6136
6137      <!-- Option 3: Re-scan recent project -->
6138      <div class="option-card" id="recent-card">
6139        <div class="card-body">
6140          <div class="card-left" style="grid-column:1/-1;">
6141            <div class="option-icon rescan">
6142              <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>
6143            </div>
6144            <div class="card-text">
6145              <div class="option-title">Re-scan a recent project</div>
6146              <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>
6147              <ul class="feature-list">
6148                <li>All 15+ settings restored from the saved config</li>
6149                <li>Path and output dir are editable before running</li>
6150                <li>Only scans with a saved config appear here</li>
6151              </ul>
6152            </div>
6153          </div>
6154        </div>
6155        <div class="section-divider"></div>
6156        <div class="recent-list" id="recent-list">
6157          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
6158        </div>
6159      </div>
6160
6161    </div>
6162  </div>
6163
6164  <footer class="site-footer">
6165    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
6166    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6167    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6168    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6169  </footer>
6170
6171  <script>
6172    (function () {
6173      var storageKey = 'oxide-sloc-theme';
6174      var body = document.body;
6175      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
6176      var toggle = document.getElementById('theme-toggle');
6177      if (toggle) toggle.addEventListener('click', function () {
6178        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
6179        body.classList.toggle('dark-theme', next === 'dark');
6180        try { localStorage.setItem(storageKey, next); } catch(e) {}
6181      });
6182
6183      (function randomizeWatermarks() {
6184        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6185        if (!wms.length) return;
6186        var placed = [];
6187        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; }
6188        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]; }
6189        var half = Math.floor(wms.length / 2);
6190        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 + ';'; });
6191      })();
6192      (function spawnCodeParticles() {
6193        var container = document.getElementById('code-particles');
6194        if (!container) return;
6195        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'];
6196        var count = 38;
6197        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); }
6198      })();
6199
6200      // Recent scans data injected from server
6201      var recentScans = {{ recent_scans_json|safe }};
6202
6203      function configToParams(cfg) {
6204        var p = new URLSearchParams();
6205        p.set('prefilled', '1');
6206        if (cfg.path) p.set('path', cfg.path);
6207        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
6208        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
6209        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
6210        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
6211        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
6212        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
6213        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
6214        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
6215        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
6216        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
6217        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
6218        if (cfg.report_title) p.set('report_title', cfg.report_title);
6219        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
6220        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
6221        return p;
6222      }
6223
6224      // Build recent scan list (capped at 3 visible entries)
6225      var list = document.getElementById('recent-list');
6226      var noNote = document.getElementById('no-recent-note');
6227      var hasAny = false;
6228      var MAX_RECENT = 3;
6229      if (Array.isArray(recentScans)) {
6230        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
6231        var shown = 0;
6232        validEntries.forEach(function (entry) {
6233          if (shown >= MAX_RECENT) return;
6234          shown++;
6235          hasAny = true;
6236          var item = document.createElement('div');
6237          item.className = 'recent-item';
6238          item.title = 'Restore all settings and open wizard';
6239          item.innerHTML =
6240            '<div class="recent-item-info">' +
6241              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
6242              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
6243            '</div>' +
6244            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
6245          item.addEventListener('click', function () {
6246            var params = configToParams(entry.config);
6247            window.location.href = '/scan?' + params.toString();
6248          });
6249          list.appendChild(item);
6250        });
6251        if (validEntries.length > MAX_RECENT) {
6252          var moreEl = document.createElement('div');
6253          moreEl.className = 'recent-more-link';
6254          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
6255          list.appendChild(moreEl);
6256        }
6257      }
6258      if (hasAny && noNote) noNote.style.display = 'none';
6259
6260      // Config file loader
6261      var fileInput = document.getElementById('config-file-input');
6262      var fileName = document.getElementById('config-file-name');
6263      if (fileInput) {
6264        fileInput.addEventListener('change', function () {
6265          var file = fileInput.files && fileInput.files[0];
6266          if (!file) return;
6267          if (fileName) fileName.textContent = '✓ ' + file.name;
6268          var reader = new FileReader();
6269          reader.onload = function (e) {
6270            try {
6271              var cfg = JSON.parse(e.target.result);
6272              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
6273              var params = configToParams(cfg);
6274              window.location.href = '/scan?' + params.toString();
6275            } catch (err) {
6276              alert('Could not parse config file: ' + err.message);
6277            }
6278          };
6279          reader.readAsText(file);
6280        });
6281      }
6282
6283      function escHtml(s) {
6284        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
6285      }
6286    })();
6287  </script>
6288</body>
6289</html>
6290"##,
6291    ext = "html"
6292)]
6293struct ScanSetupTemplate {
6294    recent_scans_json: String,
6295}
6296
6297#[derive(Template)]
6298#[template(
6299    source = r##"
6300<!doctype html>
6301<html lang="en">
6302<head>
6303  <meta charset="utf-8">
6304  <meta name="viewport" content="width=device-width, initial-scale=1">
6305  <title>OxideSLOC | {{ report_title }} | Report</title>
6306  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
6307  <style>
6308    :root {
6309      --radius: 18px;
6310      --bg: #f5efe8;
6311      --surface: rgba(255,255,255,0.82);
6312      --surface-2: #fbf7f2;
6313      --surface-3: #efe6dc;
6314      --line: #e6d0bf;
6315      --line-strong: #dcb89f;
6316      --text: #43342d;
6317      --muted: #7b675b;
6318      --muted-2: #a08777;
6319      --nav: #b85d33;
6320      --nav-2: #7a371b;
6321      --accent: #6f9bff;
6322      --accent-2: #4a78ee;
6323      --oxide: #d37a4c;
6324      --oxide-2: #b35428;
6325      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
6326      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
6327      --success-bg: #e8f5ed;
6328      --success-text: #1a8f47;
6329      --info-bg: #eef3ff;
6330      --info-text: #4467d8;
6331    }
6332
6333    body.dark-theme {
6334      --bg: #1b1511;
6335      --surface: #261c17;
6336      --surface-2: #2d221d;
6337      --surface-3: #372922;
6338      --line: #524238;
6339      --line-strong: #6c5649;
6340      --text: #f5ece6;
6341      --muted: #c7b7aa;
6342      --muted-2: #aa9485;
6343      --nav: #b85d33;
6344      --nav-2: #7a371b;
6345      --accent: #6f9bff;
6346      --accent-2: #4a78ee;
6347      --oxide: #d37a4c;
6348      --oxide-2: #b35428;
6349      --shadow: 0 18px 42px rgba(0,0,0,0.28);
6350      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
6351      --success-bg: #163927;
6352      --success-text: #8fe2a8;
6353      --info-bg: #1c2847;
6354      --info-text: #a9c1ff;
6355    }
6356
6357    * { box-sizing: border-box; }
6358    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); }
6359    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
6360    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
6361    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
6362    .top-nav, .page { position: relative; z-index: 2; }
6363    .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); }
6364    .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; }
6365    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
6366    .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)); }
6367    .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; }
6368    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
6369    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
6370    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
6371    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
6372    .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; }
6373    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
6374    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
6375    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
6376    .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); }
6377    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
6378    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
6379    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
6380    .theme-toggle .icon-sun { display:none; }
6381    body.dark-theme .theme-toggle .icon-sun { display:block; }
6382    body.dark-theme .theme-toggle .icon-moon { display:none; }
6383    .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; }
6384    .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;}
6385    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
6386    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
6387    .hero, .panel { padding: 22px; }
6388    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
6389    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
6390    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
6391    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
6392    .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; }
6393    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
6394    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
6395    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
6396    .delta-chip.pos { background:#e6f4ea; color:#1e7e34; }
6397    .delta-chip.neg { background:#fde8e8; color:#b91c1c; }
6398    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; }
6399    .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 12px; text-align:center; min-width:80px; }
6400    .delta-card-val { font-size:16px; font-weight:800; }
6401    .delta-card-val.pos { color:#1e7e34; }
6402    .delta-card-val.neg { color:#b91c1c; }
6403    .delta-card-val.mod { color:#b35428; }
6404    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
6405    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
6406    .compare-ts { font-size:13px; color:var(--muted); }
6407    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
6408    .compare-arrow { color: var(--muted); }
6409    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
6410    .action-card { padding: 16px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6411    .action-card h3 { margin:0 0 10px; font-size: 16px; }
6412    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; }
6413    .button, .copy-button {
6414      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;
6415    }
6416    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
6417    .path-list { display: grid; grid-template-columns: 1fr 0.6fr 1.4fr; gap: 10px; margin-top: 18px; }
6418    .path-item { padding: 10px 14px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: space-between; }
6419    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
6420    .path-item strong { display: block; margin-bottom: 6px; }
6421    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
6422    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
6423    .path-subitem { flex: 1; }
6424    .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); }
6425    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); }
6426    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
6427    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
6428    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
6429    th:first-child, td:first-child { width: 28%; }
6430    th { color: var(--muted); font-weight: 700; }
6431    tr:last-child td { border-bottom: none; }
6432    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
6433    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
6434    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
6435    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
6436    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
6437    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
6438    .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; }
6439    .soft-chip.success { background: var(--success-bg); color: var(--success-text); }
6440    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
6441    .muted { color: var(--muted); }
6442    .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; }
6443    .site-footer a { color: var(--muted-2); font-weight: 700; text-decoration: none; }
6444    .site-footer a:hover { color: var(--text); text-decoration: underline; }
6445    .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; }
6446    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
6447    .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; }
6448    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
6449    /* Submodule panel */
6450    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
6451    /* Metrics tables stack */
6452    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
6453    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
6454    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
6455    .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)); }
6456    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
6457    /* Metrics table */
6458    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
6459    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
6460    .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; }
6461    .metrics-table thead th:not(:first-child) { text-align: right; }
6462    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
6463    .metrics-table tbody tr:last-child td { border-bottom: none; }
6464    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
6465    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
6466    .metrics-table tbody tr:hover td { background: var(--surface-2); }
6467    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
6468    .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; }
6469    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
6470    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
6471    .mt-val-pos { color: #1e7e34; font-weight: 700; }
6472    .mt-val-neg { color: #b91c1c; font-weight: 700; }
6473    .mt-val-zero { color: var(--muted); }
6474    .mt-val-mod { color: var(--oxide-2); }
6475    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
6476    @media (max-width: 1180px) {
6477      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
6478      .nav-project-slot, .nav-status { justify-content:flex-start; }
6479      .hero-top { flex-direction: column; }
6480    }
6481    .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;}
6482    @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));}}
6483  </style>
6484</head>
6485<body>
6486  <div class="background-watermarks" aria-hidden="true">
6487    <img src="/images/logo/logo-text.png" alt="" />
6488    <img src="/images/logo/logo-text.png" alt="" />
6489    <img src="/images/logo/logo-text.png" alt="" />
6490    <img src="/images/logo/logo-text.png" alt="" />
6491    <img src="/images/logo/logo-text.png" alt="" />
6492    <img src="/images/logo/logo-text.png" alt="" />
6493    <img src="/images/logo/logo-text.png" alt="" />
6494    <img src="/images/logo/logo-text.png" alt="" />
6495    <img src="/images/logo/logo-text.png" alt="" />
6496    <img src="/images/logo/logo-text.png" alt="" />
6497    <img src="/images/logo/logo-text.png" alt="" />
6498    <img src="/images/logo/logo-text.png" alt="" />
6499    <img src="/images/logo/logo-text.png" alt="" />
6500    <img src="/images/logo/logo-text.png" alt="" />
6501  </div>
6502  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
6503  <div class="top-nav">
6504    <div class="top-nav-inner">
6505      <a class="brand" href="/">
6506        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
6507        <div class="brand-copy">
6508          <div class="brand-title">OxideSLOC</div>
6509          <div class="brand-subtitle">Local analysis workbench</div>
6510        </div>
6511      </a>
6512      <div class="nav-project-slot">
6513        <div class="nav-project-pill"><span class="nav-project-label">Project</span><span class="nav-project-value">{{ report_title }}</span></div>
6514      </div>
6515      <div class="nav-status">
6516        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
6517        <a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
6518        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
6519        <div class="server-status-wrap">
6520          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
6521          <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>
6522        </div>
6523        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
6524          <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>
6525          <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>
6526        </button>
6527      </div>
6528    </div>
6529  </div>
6530
6531  <div class="page">
6532    <section class="hero">
6533      <div class="hero-top">
6534        <div>
6535          <div class="soft-chip success">Run finished successfully</div>
6536          <h1 class="hero-title">{{ report_title }}</h1>
6537          <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>
6538        </div>
6539        <div class="hero-quick-actions">
6540          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
6541          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
6542          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
6543        </div>
6544      </div>
6545
6546      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
6547      <div class="compare-banner">
6548        <div class="compare-banner-body">
6549          <div class="compare-banner-meta">
6550            <span class="compare-label">Previous scan</span>
6551            <span class="compare-ts">{{ prev_ts }}</span>
6552            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
6553            {% if let Some(prev_code) = prev_run_code_lines %}
6554            <div class="compare-banner-stats" style="margin-top:4px;">
6555              <span>Code before: <strong>{{ prev_code }}</strong></span>
6556              <span class="compare-arrow">→</span>
6557              <span>Code now: <strong>{{ code_lines }}</strong></span>
6558              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
6559              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
6560            </div>
6561            {% endif %}
6562          </div>
6563          {% if delta_lines_added.is_some() %}
6564          <div class="delta-cards-inline">
6565            <div class="delta-card-inline">
6566              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
6567              <div class="delta-card-lbl">lines added</div>
6568            </div>
6569            <div class="delta-card-inline">
6570              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
6571              <div class="delta-card-lbl">lines removed</div>
6572            </div>
6573            <div class="delta-card-inline">
6574              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
6575              <div class="delta-card-lbl">unmodified lines</div>
6576            </div>
6577            <div class="delta-card-inline">
6578              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
6579              <div class="delta-card-lbl">files modified</div>
6580            </div>
6581            <div class="delta-card-inline">
6582              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
6583              <div class="delta-card-lbl">files added</div>
6584            </div>
6585            <div class="delta-card-inline">
6586              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
6587              <div class="delta-card-lbl">files removed</div>
6588            </div>
6589            <div class="delta-card-inline">
6590              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
6591              <div class="delta-card-lbl">files unchanged</div>
6592            </div>
6593          </div>
6594          {% else %}
6595          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
6596            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
6597          </p>
6598          {% endif %}
6599          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
6600        </div>
6601      </div>
6602      {% endif %}{% endif %}
6603
6604      <div class="action-grid">
6605        <div class="action-card">
6606          <h3>HTML report</h3>
6607          <div class="action-buttons">
6608            {% match html_url %}
6609              {% when Some with (url) %}
6610                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
6611              {% when None %}{% endmatch %}
6612            {% match html_download_url %}
6613              {% when Some with (url) %}
6614                <a class="button secondary" href="{{ url }}">Download HTML</a>
6615              {% when None %}{% endmatch %}
6616            {% match html_path %}
6617              {% when Some with (_path) %}{% when None %}{% endmatch %}
6618          </div>
6619        </div>
6620        <div class="action-card">
6621          <h3>PDF report</h3>
6622          <div class="action-buttons">
6623            {% match pdf_url %}
6624              {% when Some with (url) %}
6625                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open PDF</a>
6626              {% when None %}{% endmatch %}
6627            {% match pdf_download_url %}
6628              {% when Some with (url) %}
6629                <a class="button secondary" href="{{ url }}">Download PDF</a>
6630              {% when None %}{% endmatch %}
6631            {% match pdf_path %}
6632              {% when Some with (_path) %}{% when None %}{% endmatch %}
6633          </div>
6634        </div>
6635        <div class="action-card">
6636          <h3>JSON result</h3>
6637          <div class="action-buttons">
6638            {% match json_url %}
6639              {% when Some with (url) %}
6640                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
6641              {% when None %}{% endmatch %}
6642            {% match json_download_url %}
6643              {% when Some with (url) %}
6644                <a class="button secondary" href="{{ url }}">Download JSON</a>
6645              {% when None %}{% endmatch %}
6646            {% match json_path %}
6647              {% when Some with (_path) %}{% when None %}
6648                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
6649              {% endmatch %}
6650          </div>
6651        </div>
6652        <div class="action-card">
6653          <h3>Scan config</h3>
6654          <div class="action-buttons">
6655            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
6656            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
6657          </div>
6658          <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
6659        </div>
6660      </div>
6661      {% if !submodule_rows.is_empty() %}
6662      <div class="submodule-panel">
6663        <div class="toolbar-row">
6664          <div>
6665            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
6666            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
6667          </div>
6668          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
6669        </div>
6670        <div style="overflow:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
6671        <table style="width:100%;border-collapse:collapse;font-size:14px;">
6672          <thead>
6673            <tr>
6674              <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>
6675              <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>
6676              <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>
6677              <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>
6678              <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>
6679              <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>
6680              <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>
6681              <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>
6682            </tr>
6683          </thead>
6684          <tbody>
6685            {% for row in submodule_rows %}
6686            <tr>
6687              <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;"><strong>{{ row.name }}</strong></td>
6688              <td style="padding:10px 14px;border-bottom:1px solid var(--line);"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
6689              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
6690              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
6691              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
6692              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
6693              <td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
6694              <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>
6695            </tr>
6696            {% endfor %}
6697          </tbody>
6698        </table>
6699        </div>
6700      </div>
6701      {% endif %}
6702
6703      <div class="metrics-tables-stack">
6704
6705        <div class="metrics-table-wrap">
6706          <div class="metrics-table-title">Files</div>
6707          <table class="metrics-table">
6708            <thead>
6709              <tr>
6710                <th>Metric</th>
6711                <th>This Run</th>
6712                <th>Previous</th>
6713                <th>Change</th>
6714              </tr>
6715            </thead>
6716            <tbody>
6717              <tr>
6718                <td>Files analyzed</td>
6719                <td class="mt-val-large">{{ files_analyzed }}</td>
6720                <td>{{ prev_fa_str }}</td>
6721                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
6722              </tr>
6723              <tr>
6724                <td>Files skipped</td>
6725                <td>{{ files_skipped }}</td>
6726                <td>{{ prev_fs_str }}</td>
6727                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
6728              </tr>
6729              <tr>
6730                <td>Files modified</td>
6731                <td class="mt-val-na">—</td>
6732                <td class="mt-val-na">—</td>
6733                <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>
6734              </tr>
6735              <tr>
6736                <td>Files unchanged</td>
6737                <td class="mt-val-na">—</td>
6738                <td class="mt-val-na">—</td>
6739                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
6740              </tr>
6741            </tbody>
6742          </table>
6743        </div>
6744
6745        <div class="metrics-table-wrap">
6746          <div class="metrics-table-title">Line Counts</div>
6747          <table class="metrics-table">
6748            <thead>
6749              <tr>
6750                <th>Metric</th>
6751                <th>This Run</th>
6752                <th>Previous</th>
6753                <th>Change</th>
6754              </tr>
6755            </thead>
6756            <tbody>
6757              <tr>
6758                <td>Physical lines</td>
6759                <td class="mt-val-large">{{ physical_lines }}</td>
6760                <td>{{ prev_pl_str }}</td>
6761                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
6762              </tr>
6763              <tr>
6764                <td>Code lines</td>
6765                <td class="mt-val-large">{{ code_lines }}</td>
6766                <td>{{ prev_cl_str }}</td>
6767                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
6768              </tr>
6769              <tr>
6770                <td>Comment lines</td>
6771                <td>{{ comment_lines }}</td>
6772                <td>{{ prev_cml_str }}</td>
6773                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
6774              </tr>
6775              <tr>
6776                <td>Blank lines</td>
6777                <td>{{ blank_lines }}</td>
6778                <td>{{ prev_bl_str }}</td>
6779                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
6780              </tr>
6781              <tr>
6782                <td>Mixed (separate)</td>
6783                <td>{{ mixed_lines }}</td>
6784                <td class="mt-val-na">—</td>
6785                <td class="mt-val-na">—</td>
6786              </tr>
6787            </tbody>
6788          </table>
6789        </div>
6790
6791        <div class="metrics-tables-lower">
6792          <div class="metrics-table-wrap">
6793            <div class="metrics-table-title">Code Structure</div>
6794            <table class="metrics-table">
6795              <thead>
6796                <tr>
6797                  <th>Metric</th>
6798                  <th>This Run</th>
6799                </tr>
6800              </thead>
6801              <tbody>
6802                <tr>
6803                  <td>Functions</td>
6804                  <td>{{ functions }}</td>
6805                </tr>
6806                <tr>
6807                  <td>Classes / Types</td>
6808                  <td>{{ classes }}</td>
6809                </tr>
6810                <tr>
6811                  <td>Variables</td>
6812                  <td>{{ variables }}</td>
6813                </tr>
6814                <tr>
6815                  <td>Imports</td>
6816                  <td>{{ imports }}</td>
6817                </tr>
6818              </tbody>
6819            </table>
6820          </div>
6821
6822          <div class="metrics-table-wrap">
6823            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
6824            <table class="metrics-table">
6825              <thead>
6826                <tr>
6827                  <th>Metric</th>
6828                  <th>Change</th>
6829                </tr>
6830              </thead>
6831              <tbody>
6832                <tr>
6833                  <td>Lines added</td>
6834                  <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>
6835                </tr>
6836                <tr>
6837                  <td>Lines removed</td>
6838                  <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>
6839                </tr>
6840                <tr>
6841                  <td>Lines modified (net)</td>
6842                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
6843                </tr>
6844                <tr>
6845                  <td>Lines unmodified</td>
6846                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
6847                </tr>
6848              </tbody>
6849            </table>
6850          </div>
6851        </div>
6852
6853      </div>
6854
6855      <div class="path-list">
6856        <div class="path-item">
6857          <div class="path-item-label">Project path</div>
6858          <code>{{ project_path }}</code>
6859        </div>
6860        <div class="path-item">
6861          <div class="path-item-label">Git branch</div>
6862          {% if let Some(branch) = git_branch %}
6863          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
6864          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
6865          {% else %}
6866          <code style="color:var(--muted)">—</code>
6867          {% endif %}
6868        </div>
6869        <div class="path-item path-item-split">
6870          <div class="path-subitem">
6871            <div class="path-item-label">Output folder</div>
6872            <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
6873          </div>
6874          <div class="path-subitem" style="border-top:1px solid var(--line);padding-top:8px;margin-top:8px;">
6875            <div class="path-item-label">Run ID</div>
6876            <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
6877              <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
6878              <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
6879            </div>
6880          </div>
6881        </div>
6882      </div>
6883    </section>
6884
6885    <section class="panel" style="margin-bottom: 18px;">
6886        <div class="toolbar-row">
6887          <div>
6888            <h2>Language breakdown</h2>
6889            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
6890          </div>
6891        </div>
6892        <table>
6893          <thead>
6894            <tr>
6895              <th>Language</th>
6896              <th>Files</th>
6897              <th>Physical</th>
6898              <th>Code</th>
6899              <th>Comments</th>
6900              <th>Blank</th>
6901              <th>Mixed</th>
6902              <th>Functions</th>
6903              <th>Classes</th>
6904              <th>Variables</th>
6905              <th>Imports</th>
6906            </tr>
6907          </thead>
6908          <tbody>
6909            {% for row in language_rows %}
6910            <tr>
6911              <td>{{ row.language }}</td>
6912              <td>{{ row.files }}</td>
6913              <td>{{ row.physical }}</td>
6914              <td>{{ row.code }}</td>
6915              <td>{{ row.comments }}</td>
6916              <td>{{ row.blank }}</td>
6917              <td>{{ row.mixed }}</td>
6918              <td>{{ row.functions }}</td>
6919              <td>{{ row.classes }}</td>
6920              <td>{{ row.variables }}</td>
6921              <td>{{ row.imports }}</td>
6922            </tr>
6923            {% endfor %}
6924          </tbody>
6925        </table>
6926    </section>
6927
6928  </div>
6929
6930  <script>
6931    (function () {
6932      var body = document.body;
6933      var themeToggle = document.getElementById('theme-toggle');
6934      var storageKey = 'oxide-sloc-theme';
6935
6936      function applyTheme(theme) {
6937        body.classList.toggle('dark-theme', theme === 'dark');
6938      }
6939
6940      function loadSavedTheme() {
6941        try {
6942          var saved = localStorage.getItem(storageKey);
6943          if (saved === 'dark' || saved === 'light') {
6944            applyTheme(saved);
6945          }
6946        } catch (e) {}
6947      }
6948
6949      if (themeToggle) {
6950        themeToggle.addEventListener('click', function () {
6951          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
6952          applyTheme(nextTheme);
6953          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
6954        });
6955      }
6956
6957      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
6958        button.addEventListener('click', function () {
6959          var value = button.getAttribute('data-copy-value') || '';
6960          if (!value) return;
6961          if (navigator.clipboard && navigator.clipboard.writeText) {
6962            navigator.clipboard.writeText(value).catch(function () {});
6963          }
6964        });
6965      });
6966
6967      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
6968        btn.addEventListener('click', function () {
6969          var folder = btn.getAttribute('data-folder') || '';
6970          if (!folder) return;
6971          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
6972        });
6973      });
6974
6975      loadSavedTheme();
6976
6977      (function randomizeWatermarks() {
6978        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
6979        if (!wms.length) return;
6980        var placed = [];
6981        function tooClose(top, left) {
6982          for (var i = 0; i < placed.length; i++) {
6983            var dt = Math.abs(placed[i][0] - top);
6984            var dl = Math.abs(placed[i][1] - left);
6985            if (dt < 20 && dl < 18) return true;
6986          }
6987          return false;
6988        }
6989        function pick(leftBand) {
6990          for (var attempt = 0; attempt < 50; attempt++) {
6991            var top = Math.random() * 85 + 5;
6992            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
6993            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
6994          }
6995          var top = Math.random() * 85 + 5;
6996          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
6997          placed.push([top, left]);
6998          return [top, left];
6999        }
7000        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
7001        var half = Math.floor(wms.length / 2);
7002        wms.forEach(function (img, i) {
7003          var pos = pick(i < half);
7004          var size = Math.floor(Math.random() * 100 + 160);
7005          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
7006          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
7007          img.style.cssText = "width:" + size + "px;top:" + pos[0].toFixed(1) + "%;left:" + pos[1].toFixed(1) + "%;transform:rotate(" + rot.toFixed(1) + "deg);opacity:" + op + ";";
7008        });
7009      })();
7010
7011      (function spawnCodeParticles() {
7012        var container = document.getElementById('code-particles');
7013        if (!container) return;
7014        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'];
7015        for (var i = 0; i < 38; i++) {
7016          (function(idx) {
7017            var el = document.createElement('span');
7018            el.className = 'code-particle';
7019            el.textContent = snippets[idx % snippets.length];
7020            var left = Math.random() * 94 + 2;
7021            var top = Math.random() * 88 + 6;
7022            var dur = (Math.random() * 10 + 9).toFixed(1);
7023            var delay = (Math.random() * 18).toFixed(1);
7024            var rot = (Math.random() * 26 - 13).toFixed(1);
7025            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7026            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7027            container.appendChild(el);
7028          })(i);
7029        }
7030      })();
7031    })();
7032  </script>
7033  <footer class="site-footer">
7034    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
7035    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7036    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7037    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7038  </footer>
7039</body>
7040</html>
7041"##,
7042    ext = "html"
7043)]
7044struct ResultTemplate {
7045    report_title: String,
7046    project_path: String,
7047    output_dir: String,
7048    run_id: String,
7049    files_analyzed: u64,
7050    files_skipped: u64,
7051    physical_lines: u64,
7052    code_lines: u64,
7053    comment_lines: u64,
7054    blank_lines: u64,
7055    mixed_lines: u64,
7056    functions: u64,
7057    classes: u64,
7058    variables: u64,
7059    imports: u64,
7060    html_url: Option<String>,
7061    pdf_url: Option<String>,
7062    json_url: Option<String>,
7063    html_download_url: Option<String>,
7064    pdf_download_url: Option<String>,
7065    json_download_url: Option<String>,
7066    html_path: Option<String>,
7067    pdf_path: Option<String>,
7068    json_path: Option<String>,
7069    language_rows: Vec<LanguageSummaryRow>,
7070    prev_run_id: Option<String>,
7071    prev_run_timestamp: Option<String>,
7072    prev_run_code_lines: Option<u64>,
7073    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
7074    prev_fa_str: String,
7075    prev_fs_str: String,
7076    prev_pl_str: String,
7077    prev_cl_str: String,
7078    prev_cml_str: String,
7079    prev_bl_str: String,
7080    // Signed change column for main metrics
7081    delta_fa_str: String,
7082    delta_fa_class: String,
7083    delta_fs_str: String,
7084    delta_fs_class: String,
7085    delta_pl_str: String,
7086    delta_pl_class: String,
7087    delta_cl_str: String,
7088    delta_cl_class: String,
7089    delta_cml_str: String,
7090    delta_cml_class: String,
7091    delta_bl_str: String,
7092    delta_bl_class: String,
7093    // delta vs previous scan
7094    delta_lines_added: Option<i64>,
7095    delta_lines_removed: Option<i64>,
7096    delta_lines_net_str: String,
7097    delta_lines_net_class: String,
7098    delta_files_added: Option<usize>,
7099    delta_files_removed: Option<usize>,
7100    delta_files_modified: Option<usize>,
7101    delta_files_unchanged: Option<usize>,
7102    delta_unmodified_lines: Option<u64>,
7103    // git context
7104    git_branch: Option<String>,
7105    git_commit: Option<String>,
7106    git_author: Option<String>,
7107    // history
7108    prev_scan_count: usize,
7109    current_scan_number: usize,
7110    // submodule breakdown (empty when not requested)
7111    submodule_rows: Vec<SubmoduleRow>,
7112    scan_config_url: String,
7113}
7114
7115#[derive(Template)]
7116#[template(
7117    source = r##"
7118<!doctype html>
7119<html lang="en">
7120<head>
7121  <meta charset="utf-8">
7122  <meta name="viewport" content="width=device-width, initial-scale=1">
7123  <title>OxideSLOC | Error</title>
7124  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7125  <style>
7126    :root {
7127      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
7128      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7129      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#4a78ee;
7130      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7131    }
7132    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7133    *{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);}
7134    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7135    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7136    .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);}
7137    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7138    .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));}
7139    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7140    .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;}
7141    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7142    .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;}
7143    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7144    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7145    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7146    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7147    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7148    .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
7149    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
7150    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
7151    .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;}
7152    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
7153    .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);}
7154    .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;}
7155    .btn-secondary:hover{background:var(--line);}
7156    .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;}
7157    .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;}
7158    .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;}
7159    @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));}}
7160  </style>
7161</head>
7162<body>
7163  <div class="background-watermarks" aria-hidden="true">
7164    <img src="/images/logo/logo-text.png" alt="" style="width:320px;top:-40px;left:-60px;transform:rotate(-12deg);" />
7165    <img src="/images/logo/logo-text.png" alt="" style="width:280px;top:120px;right:-50px;transform:rotate(8deg);" />
7166    <img src="/images/logo/logo-text.png" alt="" style="width:260px;bottom:60px;left:30px;transform:rotate(15deg);" />
7167    <img src="/images/logo/logo-text.png" alt="" style="width:300px;bottom:-20px;right:80px;transform:rotate(-6deg);" />
7168    <img src="/images/logo/logo-text.png" alt="" style="width:240px;top:50%;left:45%;transform:rotate(22deg);" />
7169    <img src="/images/logo/logo-text.png" alt="" style="width:270px;top:10%;left:35%;transform:rotate(-18deg);" />
7170  </div>
7171  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7172  <div class="top-nav">
7173    <div class="top-nav-inner">
7174      <a class="brand" href="/">
7175        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
7176        <div class="brand-copy">
7177          <div class="brand-title">OxideSLOC</div>
7178          <div class="brand-subtitle">Local analysis workbench</div>
7179        </div>
7180      </a>
7181      <div class="nav-right">
7182        <a class="nav-pill" href="/">Home</a>
7183        <a class="nav-pill" href="/view-reports">View Reports</a>
7184        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7185        <div class="server-status-wrap">
7186          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7187          <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>
7188        </div>
7189        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7190          <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>
7191          <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>
7192        </button>
7193      </div>
7194    </div>
7195  </div>
7196
7197  <div class="page">
7198    <div class="panel">
7199      <h1>Analysis failed</h1>
7200      <div class="error-box">{{ message }}</div>
7201      <div class="actions">
7202        <a class="btn-primary" href="/scan">Back to setup</a>
7203        {% if let Some(report_url) = last_report_url %}
7204        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
7205        {% endif %}
7206        <a class="btn-secondary" href="/view-reports">View Reports</a>
7207      </div>
7208    </div>
7209  </div>
7210  <script>
7211    (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");});})();
7212    (function spawnCodeParticles() {
7213      var container = document.getElementById('code-particles');
7214      if (!container) return;
7215      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'];
7216      for (var i = 0; i < 38; i++) {
7217        (function(idx) {
7218          var el = document.createElement('span');
7219          el.className = 'code-particle';
7220          el.textContent = snippets[idx % snippets.length];
7221          var left = Math.random() * 94 + 2;
7222          var top = Math.random() * 88 + 6;
7223          var dur = (Math.random() * 10 + 9).toFixed(1);
7224          var delay = (Math.random() * 18).toFixed(1);
7225          var rot = (Math.random() * 26 - 13).toFixed(1);
7226          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7227          el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7228          container.appendChild(el);
7229        })(i);
7230      }
7231    })();
7232  </script>
7233</body>
7234</html>
7235"##,
7236    ext = "html"
7237)]
7238struct ErrorTemplate {
7239    message: String,
7240    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
7241    last_report_url: Option<String>,
7242    /// Label for the secondary action button; defaults to "View last report" when None.
7243    last_report_label: Option<String>,
7244}
7245
7246// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
7247
7248#[derive(Template)]
7249#[template(
7250    source = r##"
7251<!doctype html>
7252<html lang="en">
7253<head>
7254  <meta charset="utf-8">
7255  <meta name="viewport" content="width=device-width, initial-scale=1">
7256  <title>OxideSLOC | View Reports</title>
7257  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7258  <style>
7259    :root {
7260      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7261      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7262      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7263      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7264      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea;
7265    }
7266    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7267    *{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);}
7268    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7269    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7270    .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);}
7271    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7272    .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));}
7273    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7274    .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;}
7275    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7276    .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;}
7277    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7278    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7279    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7280    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7281    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7282    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7283    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7284    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7285    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7286    .panel-meta{font-size:13px;color:var(--muted);}
7287    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7288    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7289    .per-page-label{font-size:13px;color:var(--muted);}
7290    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;}
7291    .filter-input{min-width:180px;cursor:text;}
7292    .table-wrap{width:100%;overflow-x:auto;}
7293    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7294    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;}
7295    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
7296    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7297    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
7298    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7299    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
7300    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7301    tr:last-child td{border-bottom:none;}
7302    tr:hover td{background:var(--surface-2);}
7303    .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);}
7304    .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);}
7305    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7306    .metric-num{font-weight:700;color:var(--text);}
7307    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7308    .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;}
7309    .btn:hover{background:var(--line);}
7310    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7311    .btn.primary:hover{opacity:.9;}
7312    .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;}
7313    .btn-back:hover{background:var(--line);}
7314    .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;}
7315    .export-btn:hover{background:var(--line);}
7316    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
7317    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
7318    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
7319    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7320    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7321    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7322    .pagination-info{font-size:13px;color:var(--muted);}
7323    .pagination-btns{display:flex;gap:6px;}
7324    .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;}
7325    .pg-btn:hover:not(:disabled){background:var(--line);}
7326    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
7327    .pg-btn:disabled{opacity:.35;cursor:default;}
7328    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7329    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7330    .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;}
7331    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7332    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7333    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7334    .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);}
7335    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7336    .stat-chip:hover .stat-chip-tip{opacity:1;}
7337    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7338    .site-footer a{color:var(--muted);}
7339    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7340    .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%;}
7341    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
7342    .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;}
7343    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
7344    .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;}
7345    .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;}
7346    .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;}
7347    @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));}}
7348  </style>
7349</head>
7350<body>
7351  <div class="background-watermarks" aria-hidden="true">
7352    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7353    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7354    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7355    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7356    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7357    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7358  </div>
7359  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7360  <div class="top-nav">
7361    <div class="top-nav-inner">
7362      <a class="brand" href="/">
7363        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7364        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
7365      </a>
7366      <div class="nav-right">
7367        <a class="nav-pill" href="/">Home</a>
7368        <a class="nav-pill" href="/view-reports">View Reports</a>
7369        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7370        <div class="server-status-wrap">
7371          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7372          <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>
7373        </div>
7374        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7375          <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>
7376          <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>
7377        </button>
7378      </div>
7379    </div>
7380  </div>
7381
7382  <div class="page">
7383    {% if linked %}
7384    <div class="toast-success">
7385      <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>
7386      Report linked successfully — it now appears in the list below.
7387    </div>
7388    {% endif %}
7389    {% if total_scans > 0 %}
7390    <div class="summary-strip">
7391      <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>
7392      <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>
7393      <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>
7394      <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>
7395    </div>
7396    {% endif %}
7397
7398    <section class="panel">
7399      <div class="panel-header">
7400        <div>
7401          <h1>View Reports</h1>
7402          <p class="panel-meta">{{ total_scans }} report(s) available. Click any row to open it.</p>
7403        </div>
7404        <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7405          <div class="export-group">
7406            <button type="button" class="export-btn" onclick="exportHistoryCsv()">
7407              <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>
7408              Export CSV
7409            </button>
7410            <button type="button" class="export-btn" onclick="exportHistoryXls()">
7411              <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>
7412              Export Excel
7413            </button>
7414          </div>
7415          <a class="btn-back" href="/">
7416            <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>
7417            Home
7418          </a>
7419        </div>
7420      </div>
7421
7422      <div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap;">
7423        <span class="locate-label" style="white-space:nowrap;">Have a saved report on disk? Browse to link it here.</span>
7424        {% if !entries.is_empty() %}
7425        <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7426          <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7427          <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7428          <button type="button" class="btn" onclick="resetView()">&#8635; Reset view</button>
7429        </div>
7430        {% endif %}
7431      </div>
7432      <div style="margin-bottom:14px;">
7433        <button type="button" class="btn" onclick="browseReport()">
7434          <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>
7435          Browse for Report…
7436        </button>
7437      </div>
7438
7439      {% if entries.is_empty() %}
7440      <div class="empty-state">
7441        <strong>No reports with viewable HTML yet</strong>
7442        Run a new analysis from the <a href="/scan">scan page</a>, or use the browse button above to link an existing report.
7443      </div>
7444      {% else %}
7445      <div class="table-wrap">
7446        <table id="history-table">
7447          <colgroup>
7448            <col style="width:155px">
7449            <col style="width:160px">
7450            <col style="width:115px">
7451            <col style="width:88px">
7452            <col style="width:88px">
7453            <col style="width:88px">
7454            <col style="width:72px">
7455            <col style="width:80px">
7456            <col style="width:76px">
7457            <col style="width:80px">
7458            <col style="width:72px">
7459            <col style="width:92px">
7460            <col style="width:92px">
7461            <col style="width:160px">
7462          </colgroup>
7463          <thead>
7464            <tr id="history-thead">
7465              <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>
7466              <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>
7467              <th>Run ID<div class="col-resize-handle"></div></th>
7468              <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>
7469              <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>
7470              <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>
7471              <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>
7472              <th>Functions<div class="col-resize-handle"></div></th>
7473              <th>Classes<div class="col-resize-handle"></div></th>
7474              <th>Variables<div class="col-resize-handle"></div></th>
7475              <th>Imports<div class="col-resize-handle"></div></th>
7476              <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>
7477              <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>
7478              <th>Report<div class="col-resize-handle"></div></th>
7479            </tr>
7480          </thead>
7481          <tbody id="history-tbody">
7482            {% for entry in entries %}
7483            <tr class="history-row" data-run="{{ entry.run_id }}"
7484                data-timestamp="{{ entry.timestamp }}"
7485                data-project="{{ entry.project_label }}"
7486                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
7487                data-skipped="{{ entry.files_skipped }}"
7488                data-comments="{{ entry.comment_lines }}"
7489                data-blank="{{ entry.blank_lines }}"
7490                data-branch="{{ entry.git_branch }}"
7491                data-commit="{{ entry.git_commit }}"
7492                style="cursor:pointer;"
7493                onclick="window.open('/runs/{{ entry.run_id }}/html', '_blank')">
7494              <td>{{ entry.timestamp }}</td>
7495              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7496              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
7497              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
7498              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
7499              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
7500              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
7501              <td><span class="metric-num">{{ entry.functions }}</span></td>
7502              <td><span class="metric-num">{{ entry.classes }}</span></td>
7503              <td><span class="metric-num">{{ entry.variables }}</span></td>
7504              <td><span class="metric-num">{{ entry.imports }}</span></td>
7505              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
7506              <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>
7507              <td style="overflow:visible;white-space:normal;">
7508                <div class="actions-cell">
7509                  <a class="btn primary" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="View HTML report">View</a>
7510                  {% 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 %}
7511                </div>
7512              </td>
7513            </tr>
7514            {% endfor %}
7515          </tbody>
7516        </table>
7517      </div>
7518      <div class="pagination">
7519        <span class="pagination-info" id="pagination-info"></span>
7520        <div class="pagination-btns" id="pagination-btns"></div>
7521        <div style="display:flex;align-items:center;gap:8px;">
7522          <span class="per-page-label">Show</span>
7523          <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
7524            <option value="10">10 per page</option>
7525            <option value="25" selected>25 per page</option>
7526            <option value="50">50 per page</option>
7527            <option value="100">100 per page</option>
7528          </select>
7529          <span class="per-page-label" id="page-range-label"></span>
7530        </div>
7531      </div>
7532      {% endif %}
7533    </section>
7534  </div>
7535
7536  <footer class="site-footer">
7537    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
7538    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7539    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7540    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7541  </footer>
7542
7543  <script>
7544    (function () {
7545      // ── Theme ──────────────────────────────────────────────────────────────
7546      var storageKey = 'oxide-sloc-theme';
7547      var body = document.body;
7548      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
7549      var toggle = document.getElementById('theme-toggle');
7550      if (toggle) toggle.addEventListener('click', function () {
7551        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
7552        body.classList.toggle('dark-theme', next === 'dark');
7553        try { localStorage.setItem(storageKey, next); } catch(e) {}
7554      });
7555
7556      // ── State ─────────────────────────────────────────────────────────────
7557      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
7558      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
7559      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
7560
7561      // Aggregate stats from first (most recent) row
7562      if (allRows.length) {
7563        var first = allRows[0];
7564        var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
7565        var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
7566        var se = document.getElementById('agg-skipped'); if (se) se.textContent = first.dataset.skipped;
7567      }
7568
7569      // ── Branch filter population ──────────────────────────────────────────
7570      (function() {
7571        var branches = {};
7572        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
7573        var sel = document.getElementById('branch-filter');
7574        if (sel) Object.keys(branches).sort().forEach(function(b) {
7575          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
7576        });
7577      })();
7578
7579      // ── Filter ────────────────────────────────────────────────────────────
7580      function getFilteredRows() {
7581        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
7582        var branch = ((document.getElementById('branch-filter') || {}).value || '');
7583        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
7584          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
7585          if (branch && (r.dataset.branch || '') !== branch) return false;
7586          return true;
7587        });
7588      }
7589
7590      // ── Pagination ────────────────────────────────────────────────────────
7591      function renderPage() {
7592        var filtered = getFilteredRows();
7593        var total = filtered.length;
7594        var totalPages = Math.max(1, Math.ceil(total / perPage));
7595        currentPage = Math.min(currentPage, totalPages);
7596        var start = (currentPage - 1) * perPage;
7597        var end = Math.min(start + perPage, total);
7598        var shown = {};
7599        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
7600        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
7601          r.style.display = shown[r.dataset.run] ? '' : 'none';
7602        });
7603        var rl = document.getElementById('page-range-label');
7604        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
7605        var info = document.getElementById('pagination-info');
7606        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
7607        var btns = document.getElementById('pagination-btns');
7608        if (!btns) return;
7609        btns.innerHTML = '';
7610        function makeBtn(lbl, pg, active, disabled) {
7611          var b = document.createElement('button');
7612          b.className = 'pg-btn' + (active ? ' active' : '');
7613          b.textContent = lbl; b.disabled = disabled;
7614          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
7615          return b;
7616        }
7617        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
7618        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
7619        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
7620        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
7621      }
7622
7623      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
7624      window.applyFilters = function() { currentPage = 1; renderPage(); };
7625
7626      // ── Sorting ───────────────────────────────────────────────────────────
7627      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
7628      function doSort(col, type, order) {
7629        var tbody = document.getElementById('history-tbody');
7630        if (!tbody) return;
7631        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
7632        rows.sort(function(a, b) {
7633          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
7634          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
7635          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
7636          return va < vb ? 1 : va > vb ? -1 : 0;
7637        });
7638        rows.forEach(function(r) { tbody.appendChild(r); });
7639        currentPage = 1; renderPage();
7640      }
7641      sortHeaders.forEach(function(th) {
7642        th.addEventListener('click', function(e) {
7643          if (e.target.classList.contains('col-resize-handle')) return;
7644          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
7645          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
7646          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
7647          th.classList.add('sort-' + sortOrder);
7648          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
7649          doSort(col, type, sortOrder);
7650        });
7651      });
7652
7653      // ── Column resize ─────────────────────────────────────────────────────
7654      (function() {
7655        var table = document.getElementById('history-table');
7656        if (!table) return;
7657        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
7658        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
7659        ths.forEach(function(th, i) {
7660          var handle = th.querySelector('.col-resize-handle');
7661          if (!handle || !cols[i]) return;
7662          var startX, startW;
7663          handle.addEventListener('mousedown', function(e) {
7664            e.stopPropagation(); e.preventDefault();
7665            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
7666            handle.classList.add('dragging');
7667            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
7668            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
7669            document.addEventListener('mousemove', onMove);
7670            document.addEventListener('mouseup', onUp);
7671          });
7672        });
7673      })();
7674
7675      // ── Reset view ────────────────────────────────────────────────────────
7676      window.resetView = function() {
7677        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
7678        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
7679        sortCol = null; sortOrder = 'asc';
7680        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
7681        var tbody = document.getElementById('history-tbody');
7682        if (tbody) {
7683          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
7684          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
7685          rows.forEach(function(r) { tbody.appendChild(r); });
7686        }
7687        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
7688        var table = document.getElementById('history-table');
7689        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7690        currentPage = 1; renderPage();
7691      };
7692
7693      renderPage();
7694
7695      (function randomizeWatermarks() {
7696        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7697        if (!wms.length) return;
7698        var placed = [];
7699        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;}
7700        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];}
7701        var half=Math.floor(wms.length/2);
7702        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+';';});
7703      })();
7704
7705      (function spawnCodeParticles() {
7706        var container = document.getElementById('code-particles');
7707        if (!container) return;
7708        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'];
7709        for (var i = 0; i < 38; i++) {
7710          (function(idx) {
7711            var el = document.createElement('span');
7712            el.className = 'code-particle';
7713            el.textContent = snippets[idx % snippets.length];
7714            var left = Math.random() * 94 + 2;
7715            var top = Math.random() * 88 + 6;
7716            var dur = (Math.random() * 10 + 9).toFixed(1);
7717            var delay = (Math.random() * 18).toFixed(1);
7718            var rot = (Math.random() * 26 - 13).toFixed(1);
7719            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7720            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
7721            container.appendChild(el);
7722          })(i);
7723        }
7724      })();
7725    })();
7726
7727    function rowClick(runId, hasHtml) {
7728      if (hasHtml) window.open('/runs/' + runId + '/html', '_blank');
7729    }
7730
7731    function browseReport() {
7732      fetch('/pick-file?kind=report')
7733        .then(function(r) { return r.json(); })
7734        .then(function(data) {
7735          if (!data.cancelled && data.selected_path) {
7736            var form = document.createElement('form');
7737            form.method = 'POST';
7738            form.action = '/locate-report';
7739            var input = document.createElement('input');
7740            input.type = 'hidden';
7741            input.name = 'file_path';
7742            input.value = data.selected_path;
7743            form.appendChild(input);
7744            document.body.appendChild(form);
7745            form.submit();
7746          }
7747        })
7748        .catch(function(e) { alert('Could not open file picker: ' + e); });
7749    }
7750
7751    // ── Export helpers ────────────────────────────────────────────────────────
7752    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
7753    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7754    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);}
7755    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;');}
7756    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');}
7757
7758    var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
7759    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;}
7760    window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
7761    window.exportHistoryXls = function(){slocXls('scan-history.xls','Scan History',_hh,getHistoryRows());};
7762  </script>
7763</body>
7764</html>
7765"##,
7766    ext = "html"
7767)]
7768struct HistoryTemplate {
7769    entries: Vec<HistoryEntryRow>,
7770    total_scans: usize,
7771    linked: bool,
7772}
7773
7774// ── CompareSelectTemplate ──────────────────────────────────────────────────────
7775
7776#[derive(Template)]
7777#[template(
7778    source = r##"
7779<!doctype html>
7780<html lang="en">
7781<head>
7782  <meta charset="utf-8">
7783  <meta name="viewport" content="width=device-width, initial-scale=1">
7784  <title>OxideSLOC | Compare Scans</title>
7785  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7786  <style>
7787    :root {
7788      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7789      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7790      --nav:#b85d33; --nav-2:#7a371b; --accent:#6f9bff; --accent-2:#2563eb;
7791      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7792      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
7793    }
7794    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
7795    *{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);}
7796    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
7797    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
7798    .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);}
7799    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
7800    .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));}
7801    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
7802    .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;}
7803    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
7804    .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;}
7805    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
7806    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
7807    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
7808    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
7809    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
7810    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
7811    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
7812    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
7813    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
7814    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
7815    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
7816    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
7817    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
7818    .per-page-label{font-size:13px;color:var(--muted);}
7819    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;}
7820    .filter-input{min-width:180px;cursor:text;}
7821    .table-wrap{width:100%;overflow-x:auto;}
7822    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
7823    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;}
7824    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--accent-2);}
7825    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
7826    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--accent-2);}
7827    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
7828    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(111,155,255,0.3);}
7829    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
7830    tr:last-child td{border-bottom:none;}
7831    tr.selected td{background:var(--sel-bg);}
7832    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
7833    tr:hover:not(.selected) td{background:var(--surface-2);}
7834    tr{cursor:pointer;}
7835    .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);}
7836    .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);}
7837    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
7838    .metric-num{font-weight:700;}
7839    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
7840    .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;}
7841    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
7842    .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;}
7843    .btn:hover{background:var(--line);}
7844    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
7845    .btn.primary:hover{opacity:.9;}
7846    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
7847    .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;}
7848    .btn-back:hover{background:var(--line);}
7849    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
7850    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
7851    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
7852    .pagination-info{font-size:13px;color:var(--muted);}
7853    .pagination-btns{display:flex;gap:6px;}
7854    .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;}
7855    .pg-btn:hover:not(:disabled){background:var(--line);}
7856    .pg-btn.active{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
7857    .pg-btn:disabled{opacity:.35;cursor:default;}
7858    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
7859    .site-footer a{color:var(--muted);}
7860    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
7861    .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;}
7862    .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;}
7863    .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;}
7864    @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));}}
7865    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
7866    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
7867    .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;}
7868    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
7869    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
7870    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
7871    .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);}
7872    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
7873    .stat-chip:hover .stat-chip-tip{opacity:1;}
7874    .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;}
7875    .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%;}
7876    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
7877  </style>
7878</head>
7879<body>
7880  <div class="background-watermarks" aria-hidden="true">
7881    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7882    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7883    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7884    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7885    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7886    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7887  </div>
7888  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7889  <div class="top-nav">
7890    <div class="top-nav-inner">
7891      <a class="brand" href="/">
7892        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7893        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
7894      </a>
7895      <div class="nav-right">
7896        <a class="nav-pill" href="/">Home</a>
7897        <a class="nav-pill" href="/view-reports">View Reports</a>
7898        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7899        <div class="server-status-wrap">
7900          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
7901          <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>
7902        </div>
7903        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7904          <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>
7905          <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>
7906        </button>
7907      </div>
7908    </div>
7909  </div>
7910
7911  <div class="page">
7912    {% if total_scans > 0 %}
7913    <div class="summary-strip">
7914      <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>
7915      <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>
7916      <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>
7917      <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>
7918    </div>
7919    {% endif %}
7920    <section class="panel">
7921      <div class="panel-header">
7922        <div>
7923          <h1>Compare Scans</h1>
7924          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
7925        </div>
7926        <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
7927          <button class="btn primary" id="compare-btn" onclick="doCompare()" disabled>
7928            <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>
7929            Compare <span class="sel-count" id="sel-count">0/2</span>
7930          </button>
7931          <a class="btn-back" href="/">
7932            <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>
7933            Home
7934          </a>
7935        </div>
7936      </div>
7937
7938      {% if entries.is_empty() %}
7939      <div class="empty-state">
7940        <strong>No scans yet</strong>
7941        Run your first analysis from the <a href="/scan">scan page</a>.
7942      </div>
7943      {% else %}
7944      <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;">
7945        <div class="instruction-bar" style="margin-bottom:0;flex-shrink:0;">
7946          <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>
7947          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
7948        </div>
7949        <div style="margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
7950          <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…" oninput="applyFilters()">
7951          <select class="filter-select" id="branch-filter" onchange="applyFilters()"><option value="">All branches</option></select>
7952          <button type="button" class="btn" onclick="resetView()">&#8635; Reset view</button>
7953        </div>
7954      </div>
7955      <div class="table-wrap">
7956        <table id="compare-table">
7957          <colgroup>
7958            <col style="width:44px">
7959            <col style="width:165px">
7960            <col style="width:180px">
7961            <col style="width:110px">
7962            <col style="width:100px">
7963            <col style="width:80px">
7964            <col style="width:100px">
7965            <col style="width:90px">
7966            <col style="width:100px">
7967          </colgroup>
7968          <thead>
7969            <tr id="compare-thead">
7970              <th style="text-align:center;padding-left:8px;padding-right:8px;"><div class="col-resize-handle"></div></th>
7971              <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>
7972              <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>
7973              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
7974              <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>
7975              <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>
7976              <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>
7977              <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>
7978              <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>
7979            </tr>
7980          </thead>
7981          <tbody id="compare-tbody">
7982            {% for entry in entries %}
7983            <tr class="compare-row" data-run="{{ entry.run_id }}"
7984                data-timestamp="{{ entry.timestamp }}"
7985                data-project="{{ entry.project_label }}"
7986                data-files="{{ entry.files_analyzed }}"
7987                data-code="{{ entry.code_lines }}"
7988                data-comments="{{ entry.comment_lines }}"
7989                data-branch="{{ entry.git_branch }}"
7990                data-commit="{{ entry.git_commit }}"
7991                onclick="toggleRow(this, '{{ entry.run_id }}')">
7992              <td style="text-align:center;padding-left:8px;padding-right:8px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
7993              <td>{{ entry.timestamp }}</td>
7994              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
7995              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
7996              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
7997              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
7998              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
7999              <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>
8000              <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>
8001            </tr>
8002            {% endfor %}
8003          </tbody>
8004        </table>
8005      </div>
8006      <div class="pagination">
8007        <span class="pagination-info" id="pagination-info"></span>
8008        <div class="pagination-btns" id="pagination-btns"></div>
8009        <div style="display:flex;align-items:center;gap:8px;">
8010          <span class="per-page-label">Show</span>
8011          <select class="per-page" id="per-page-sel" onchange="setPerPage(this.value)">
8012            <option value="10">10 per page</option>
8013            <option value="25" selected>25 per page</option>
8014            <option value="50">50 per page</option>
8015            <option value="100">100 per page</option>
8016          </select>
8017          <span class="per-page-label" id="page-range-label"></span>
8018        </div>
8019      </div>
8020      {% endif %}
8021    </section>
8022  </div>
8023
8024  <footer class="site-footer">
8025    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
8026    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8027    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8028    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
8029  </footer>
8030
8031  <script>
8032    (function () {
8033      // ── Theme ──────────────────────────────────────────────────────────────
8034      var storageKey = 'oxide-sloc-theme';
8035      var body = document.body;
8036      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8037      var toggle = document.getElementById('theme-toggle');
8038      if (toggle) toggle.addEventListener('click', function () {
8039        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8040        body.classList.toggle('dark-theme', next === 'dark');
8041        try { localStorage.setItem(storageKey, next); } catch(e) {}
8042      });
8043
8044      // ── State ─────────────────────────────────────────────────────────────
8045      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
8046      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
8047      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
8048
8049      // ── Stat chips ────────────────────────────────────────────────────────
8050      (function() {
8051        var projects = {}, latestTs = '', latestRow = null;
8052        allRows.forEach(function(r) {
8053          var p = r.dataset.project || ''; if (p) projects[p] = true;
8054          var ts = r.dataset.timestamp || '';
8055          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
8056        });
8057        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
8058        if (latestRow) {
8059          var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
8060          var fe = document.getElementById('agg-files'); if (fe) fe.textContent = latestRow.dataset.files;
8061        }
8062      })();
8063
8064      // ── Branch filter population ──────────────────────────────────────────
8065      (function() {
8066        var branches = {};
8067        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
8068        var sel = document.getElementById('branch-filter');
8069        if (sel) Object.keys(branches).sort().forEach(function(b) {
8070          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
8071        });
8072      })();
8073
8074      // ── Filter ────────────────────────────────────────────────────────────
8075      function getFilteredRows() {
8076        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
8077        var branch = ((document.getElementById('branch-filter') || {}).value || '');
8078        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
8079          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
8080          if (branch && (r.dataset.branch || '') !== branch) return false;
8081          return true;
8082        });
8083      }
8084
8085      // ── Pagination ────────────────────────────────────────────────────────
8086      function renderPage() {
8087        var filtered = getFilteredRows();
8088        var total = filtered.length;
8089        var totalPages = Math.max(1, Math.ceil(total / perPage));
8090        currentPage = Math.min(currentPage, totalPages);
8091        var start = (currentPage - 1) * perPage;
8092        var end = Math.min(start + perPage, total);
8093        var shown = {};
8094        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
8095        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
8096          r.style.display = shown[r.dataset.run] ? '' : 'none';
8097        });
8098        var rl = document.getElementById('page-range-label');
8099        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8100        var info = document.getElementById('pagination-info');
8101        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
8102        var btns = document.getElementById('pagination-btns');
8103        if (!btns) return;
8104        btns.innerHTML = '';
8105        function makeBtn(lbl, pg, active, disabled) {
8106          var b = document.createElement('button');
8107          b.className = 'pg-btn' + (active ? ' active' : '');
8108          b.textContent = lbl; b.disabled = disabled;
8109          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
8110          return b;
8111        }
8112        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
8113        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8114        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
8115        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
8116      }
8117
8118      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
8119      window.applyFilters = function() { currentPage = 1; renderPage(); };
8120
8121      // ── Sorting ───────────────────────────────────────────────────────────
8122      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
8123      function doSort(col, type, order) {
8124        var tbody = document.getElementById('compare-tbody');
8125        if (!tbody) return;
8126        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8127        rows.sort(function(a, b) {
8128          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
8129          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
8130          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8131          return va < vb ? 1 : va > vb ? -1 : 0;
8132        });
8133        rows.forEach(function(r) { tbody.appendChild(r); });
8134        currentPage = 1; renderPage();
8135      }
8136      sortHeaders.forEach(function(th) {
8137        th.addEventListener('click', function(e) {
8138          if (e.target.classList.contains('col-resize-handle')) return;
8139          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8140          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8141          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8142          th.classList.add('sort-' + sortOrder);
8143          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8144          doSort(col, type, sortOrder);
8145        });
8146      });
8147
8148      // ── Column resize ─────────────────────────────────────────────────────
8149      (function() {
8150        var table = document.getElementById('compare-table');
8151        if (!table) return;
8152        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8153        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
8154        ths.forEach(function(th, i) {
8155          var handle = th.querySelector('.col-resize-handle');
8156          if (!handle || !cols[i]) return;
8157          var startX, startW;
8158          handle.addEventListener('mousedown', function(e) {
8159            e.stopPropagation(); e.preventDefault();
8160            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8161            handle.classList.add('dragging');
8162            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8163            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8164            document.addEventListener('mousemove', onMove);
8165            document.addEventListener('mouseup', onUp);
8166          });
8167        });
8168      })();
8169
8170      // ── Reset view ────────────────────────────────────────────────────────
8171      window.resetView = function() {
8172        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
8173        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
8174        sortCol = null; sortOrder = 'asc';
8175        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8176        var tbody = document.getElementById('compare-tbody');
8177        if (tbody) {
8178          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
8179          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8180          rows.forEach(function(r) { tbody.appendChild(r); });
8181        }
8182        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
8183        var table = document.getElementById('compare-table');
8184        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8185        currentPage = 1; renderPage();
8186      };
8187
8188      renderPage();
8189
8190      (function randomizeWatermarks() {
8191        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8192        if (!wms.length) return;
8193        var placed = [];
8194        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;}
8195        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];}
8196        var half=Math.floor(wms.length/2);
8197        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+';';});
8198      })();
8199
8200      (function spawnCodeParticles() {
8201        var container = document.getElementById('code-particles');
8202        if (!container) return;
8203        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'];
8204        for (var i = 0; i < 38; i++) {
8205          (function(idx) {
8206            var el = document.createElement('span');
8207            el.className = 'code-particle';
8208            el.textContent = snippets[idx % snippets.length];
8209            var left = Math.random() * 94 + 2;
8210            var top = Math.random() * 88 + 6;
8211            var dur = (Math.random() * 10 + 9).toFixed(1);
8212            var delay = (Math.random() * 18).toFixed(1);
8213            var rot = (Math.random() * 26 - 13).toFixed(1);
8214            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8215            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8216            container.appendChild(el);
8217          })(i);
8218        }
8219      })();
8220    })();
8221
8222    var selected = [];
8223    function updateCompareBtn() {
8224      var btn = document.getElementById('compare-btn');
8225      var cnt = document.getElementById('sel-count');
8226      if (!btn) return;
8227      btn.disabled = selected.length !== 2;
8228      if (cnt) cnt.textContent = selected.length + '/2';
8229    }
8230
8231    function toggleRow(row, runId) {
8232      var idx = selected.indexOf(runId);
8233      if (idx >= 0) {
8234        selected.splice(idx, 1);
8235        row.classList.remove('selected');
8236        var b = document.getElementById('badge-' + runId);
8237        if (b) b.textContent = '';
8238      } else {
8239        if (selected.length >= 2) return;
8240        selected.push(runId);
8241        row.classList.add('selected');
8242        var b = document.getElementById('badge-' + runId);
8243        if (b) b.textContent = selected.length;
8244      }
8245      selected.forEach(function(id, i) {
8246        var b = document.getElementById('badge-' + id);
8247        if (b) b.textContent = i + 1;
8248      });
8249      updateCompareBtn();
8250    }
8251
8252    function doCompare() {
8253      if (selected.length !== 2) return;
8254      window.location.href = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
8255    }
8256  </script>
8257</body>
8258</html>
8259"##,
8260    ext = "html"
8261)]
8262struct CompareSelectTemplate {
8263    entries: Vec<HistoryEntryRow>,
8264    total_scans: usize,
8265}
8266
8267// ── CompareTemplate ────────────────────────────────────────────────────────────
8268
8269#[derive(Template)]
8270#[template(
8271    source = r##"
8272<!doctype html>
8273<html lang="en">
8274<head>
8275  <meta charset="utf-8">
8276  <meta name="viewport" content="width=device-width, initial-scale=1">
8277  <title>OxideSLOC | Scan Delta</title>
8278  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8279  <style>
8280    :root {
8281      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
8282      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
8283      --nav:#b85d33; --nav-2:#7a371b;
8284      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
8285      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fdeaea; --zero-bg:transparent;
8286      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
8287    }
8288    body.dark-theme {
8289      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
8290      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#f5a3a3; --neg-bg:#3d1c1c;
8291    }
8292    *{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);}
8293    .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);}
8294    .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;}
8295    .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));}
8296    .brand-copy{display:flex;flex-direction:column;justify-content:center;min-width:0;}
8297    .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;}
8298    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
8299    .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;}
8300    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
8301    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
8302    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
8303    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
8304    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
8305    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
8306    .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;}
8307    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
8308    .hero-body{display:block;}
8309    .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;}
8310    .btn-back:hover{background:var(--line);}
8311    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;}
8312    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
8313    .muted{color:var(--muted);font-size:14px;}
8314    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
8315    .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;}
8316    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
8317    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
8318    .vpill-arrow{font-size:20px;color:var(--muted);}
8319    .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%;}
8320    .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;}
8321    .delta-card.delta-card-wide{padding:14px 18px;}
8322    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);}
8323    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
8324    .delta-card-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:4px;}
8325    .delta-card-from{font-size:12px;color:var(--muted);}
8326    .delta-card-to{font-size:20px;font-weight:800;margin:2px 0;}
8327    .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;}
8328    .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);}
8329    .delta-card:hover .dc-tip{display:block;}
8330    .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;}
8331    .export-btn:hover{background:var(--line);}
8332    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
8333    .delta-card-change{font-size:13px;font-weight:700;border-radius:6px;padding:1px 7px;display:inline-block;margin-top:2px;}
8334    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
8335    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
8336    .delta-card-change.zero{color:var(--muted);background:transparent;}
8337    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
8338    .fc-row{display:flex;align-items:center;gap:8px;}
8339    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
8340    .fc-label{color:var(--muted);}
8341    .fc-modified .fc-count{color:#926000;}
8342    .fc-added .fc-count{color:var(--pos);}
8343    .fc-removed .fc-count{color:var(--neg);}
8344    .fc-unchanged .fc-count{color:var(--muted);}
8345    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
8346    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
8347    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
8348    .chip.modified{background:#fff2d8;color:#926000;}
8349    .chip.added{background:#e8f5ed;color:#1a8f47;}
8350    .chip.removed{background:#fdeaea;color:#b33b3b;}
8351    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
8352    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
8353    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
8354    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
8355    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
8356    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
8357    .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;}
8358    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
8359    .tab-btn:hover:not(.active){background:var(--line);}
8360    .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;}
8361    .btn-reset:hover{background:var(--line);}
8362    .table-wrap{width:100%;overflow-x:auto;}
8363    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
8364    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;}
8365    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
8366    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
8367    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
8368    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
8369    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
8370    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8371    tr:last-child td{border-bottom:none;}
8372    tr.row-added td{background:rgba(26,143,71,0.06);}
8373    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
8374    tr.row-modified td{background:rgba(146,96,0,0.05);}
8375    tr.row-unchanged td{opacity:.6;}
8376    .file-path{font-family:ui-monospace,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
8377    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
8378    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
8379    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
8380    .status-badge.modified{background:#fff2d8;color:#926000;}
8381    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
8382    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
8383    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
8384    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
8385    .delta-val{font-weight:700;}
8386    .delta-val.pos{color:var(--pos);}
8387    .delta-val.neg{color:var(--neg);}
8388    .delta-val.zero{color:var(--muted);}
8389    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
8390    .from-to strong{color:var(--text);}
8391    .site-footer{text-align:center;padding:18px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
8392    .site-footer a{color:var(--muted);}
8393    @media(max-width:1400px){.delta-strip{grid-template-columns:repeat(3,1fr);}}
8394    @media(max-width:900px){.delta-strip{grid-template-columns:repeat(2,1fr);}}
8395    @media(max-width:600px){.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
8396    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
8397    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
8398    .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;}
8399    .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;}
8400    .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;}
8401    @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));}}
8402    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
8403    .path-link:hover{color:var(--oxide-2);}
8404    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
8405    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
8406    a.vpill-id:hover{color:var(--oxide);}
8407    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
8408    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
8409    .pagination-info{font-size:13px;color:var(--muted);}
8410    .pagination-btns{display:flex;gap:6px;}
8411    .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;}
8412    .pg-btn:hover:not(:disabled){background:var(--line);}
8413    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8414    .pg-btn:disabled{opacity:.35;cursor:default;}
8415    .per-page-label{font-size:13px;color:var(--muted);}
8416    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;}
8417    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
8418    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
8419    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
8420    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
8421    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
8422    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
8423    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
8424    .tab-btn.tab-unchanged{color:var(--muted);}
8425    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
8426    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
8427    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
8428  </style>
8429</head>
8430<body>
8431  <div class="background-watermarks" aria-hidden="true">
8432    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8433    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8434    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8435    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8436    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8437    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
8438  </div>
8439  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
8440  <div class="top-nav">
8441    <div class="top-nav-inner">
8442      <a class="brand" href="/">
8443        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
8444        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
8445      </a>
8446      <div class="nav-right">
8447        <a class="nav-pill" href="/">Home</a>
8448        <a class="nav-pill" href="/view-reports">View Reports</a>
8449        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
8450        <div class="server-status-wrap">
8451          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Server online</div>
8452          <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>
8453        </div>
8454        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
8455          <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>
8456          <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>
8457        </button>
8458      </div>
8459    </div>
8460  </div>
8461
8462  <div class="page">
8463    <section class="hero">
8464      <div class="hero-header">
8465        <div>
8466          <h1 style="margin:0 0 6px;">Scan Delta</h1>
8467          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
8468            <span class="muted" style="font-size:13px;">Comparing two scans of</span>
8469            <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>
8470          </div>
8471          <div style="display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap;">
8472            <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:4px 10px;color:var(--muted);">
8473              <span style="color:var(--text);font-weight:700;">Baseline</span>&nbsp;&nbsp;{{ baseline_timestamp }}
8474            </span>
8475            <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>
8476            <span style="font-size:12px;background:var(--surface-2);border:1px solid var(--oxide);border-radius:8px;padding:4px 10px;color:var(--muted);">
8477              <span style="color:var(--oxide);font-weight:700;">Current</span>&nbsp;&nbsp;{{ current_timestamp }}
8478            </span>
8479          </div>
8480        </div>
8481        <a class="btn-back" href="/compare-scans">
8482          <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>
8483          Compare Scans
8484        </a>
8485      </div>
8486      <div class="hero-body">
8487      <div class="delta-strip">
8488        <div class="delta-card delta-card-meta">
8489          <div class="delta-card-label">Baseline</div>
8490          <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ baseline_timestamp }}</div>
8491          <a class="vpill-id" href="/runs/{{ baseline_run_id }}/html" target="_blank">{{ baseline_run_id_short }}</a>
8492          {% if !baseline_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ baseline_git_branch }}</span>{% endif %}
8493          {% if let Some(author) = baseline_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8494          {% if let Some(tags) = baseline_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8495        </div>
8496        <div class="delta-card delta-card-meta">
8497          <div class="delta-card-label">Current</div>
8498          <div class="delta-card-to" style="font-size:15px;line-height:1.2;">{{ current_timestamp }}</div>
8499          <a class="vpill-id" href="/runs/{{ current_run_id }}/html" target="_blank">{{ current_run_id_short }}</a>
8500          {% if !current_git_branch.is_empty() %}<span class="vpill-meta">Branch: {{ current_git_branch }}</span>{% endif %}
8501          {% if let Some(author) = current_git_author %}<span class="vpill-meta">Last commit by: {{ author }}</span>{% endif %}
8502          {% if let Some(tags) = current_git_tags %}<span class="vpill-meta">Tags: {{ tags }}</span>{% endif %}
8503        </div>
8504        <div class="delta-card">
8505          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
8506          <div class="delta-card-label">Code lines</div>
8507          <div class="delta-card-from">Before: {{ baseline_code }}</div>
8508          <div class="delta-card-to">{{ current_code }}</div>
8509          {% if code_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ code_lines_delta_str }}</span>
8510          {% else if code_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ code_lines_delta_str }}</span>
8511          {% endif %}
8512        </div>
8513        <div class="delta-card">
8514          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
8515          <div class="delta-card-label">Files analyzed</div>
8516          <div class="delta-card-from">Before: {{ baseline_files }}</div>
8517          <div class="delta-card-to">{{ current_files }}</div>
8518          {% if files_analyzed_delta_class == "pos" %}<span class="delta-card-change pos">{{ files_analyzed_delta_str }}</span>
8519          {% else if files_analyzed_delta_class == "neg" %}<span class="delta-card-change neg">{{ files_analyzed_delta_str }}</span>
8520          {% endif %}
8521        </div>
8522        <div class="delta-card">
8523          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
8524          <div class="delta-card-label">Comment lines</div>
8525          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
8526          <div class="delta-card-to">{{ current_comments }}</div>
8527          {% if comment_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ comment_lines_delta_str }}</span>
8528          {% else if comment_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ comment_lines_delta_str }}</span>
8529          {% endif %}
8530        </div>
8531        <div class="delta-card delta-card-wide">
8532          <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>
8533          <div class="delta-card-label">File changes</div>
8534          <div class="file-changes-grid">
8535            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
8536            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
8537            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
8538            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
8539          </div>
8540        </div>
8541      </div>
8542      </div>
8543    </section>
8544
8545    <section class="panel">
8546      <h2>File-level delta</h2>
8547      <div class="filter-tabs-row">
8548        <div class="filter-tabs">
8549          <button class="tab-btn tab-all active" onclick="filterRows('all', this)">All</button>
8550          <button class="tab-btn tab-modified" onclick="filterRows('modified', this)">Modified ({{ files_modified }})</button>
8551          <button class="tab-btn tab-added" onclick="filterRows('added', this)">Added ({{ files_added }})</button>
8552          <button class="tab-btn tab-removed" onclick="filterRows('removed', this)">Removed ({{ files_removed }})</button>
8553          <button class="tab-btn tab-unchanged" onclick="filterRows('unchanged', this)">Unchanged ({{ files_unchanged }})</button>
8554        </div>
8555        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
8556          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
8557          <div class="export-group">
8558            <button type="button" class="btn-reset" onclick="resetDeltaTable()">&#8635; Reset</button>
8559            <button type="button" class="export-btn" onclick="exportDeltaCsv()">
8560              <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>
8561              CSV
8562            </button>
8563            <button type="button" class="export-btn" onclick="exportDeltaXls()">
8564              <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>
8565              Excel
8566            </button>
8567            <button type="button" class="export-btn" onclick="exportDeltaCharts()">
8568              <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>
8569              Charts
8570            </button>
8571          </div>
8572        </div>
8573      </div>
8574
8575      <div class="table-wrap">
8576      <table id="delta-table">
8577        <colgroup>
8578          <col style="width:34%">
8579          <col style="width:10%">
8580          <col style="width:9%">
8581          <col style="width:15%">
8582          <col style="width:8%">
8583          <col style="width:8%">
8584          <col style="width:8%">
8585        </colgroup>
8586        <thead>
8587          <tr id="delta-thead">
8588            <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>
8589            <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>
8590            <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>
8591            <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>
8592            <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>
8593            <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>
8594            <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>
8595          </tr>
8596        </thead>
8597        <tbody id="delta-tbody">
8598          {% for row in file_rows %}
8599          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
8600              data-path="{{ row.relative_path }}"
8601              data-language="{{ row.language }}"
8602              data-baseline-code="{{ row.baseline_code }}"
8603              data-current-code="{{ row.current_code }}"
8604              data-code-delta="{{ row.code_delta_str }}"
8605              data-comment-delta="{{ row.comment_delta_str }}"
8606              data-total-delta="{{ row.total_delta_str }}"
8607              data-orig-idx="">
8608            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
8609            <td class="hide-sm">{{ row.language }}</td>
8610            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
8611            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
8612            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
8613            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
8614            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
8615          </tr>
8616          {% endfor %}
8617        </tbody>
8618      </table>
8619      </div>
8620      <div class="pagination">
8621        <span class="pagination-info" id="pg-info"></span>
8622        <div class="pagination-btns" id="pg-btns"></div>
8623        <div style="display:flex;align-items:center;gap:8px;">
8624          <span class="per-page-label">Show</span>
8625          <select class="per-page" id="per-page-sel" onchange="setDeltaPerPage(this.value)">
8626            <option value="10">10 per page</option>
8627            <option value="25" selected>25 per page</option>
8628            <option value="50">50 per page</option>
8629            <option value="100">100 per page</option>
8630          </select>
8631          <span class="per-page-label" id="pg-range-label"></span>
8632        </div>
8633      </div>
8634    </section>
8635  </div>
8636
8637  <footer class="site-footer">
8638    oxide-sloc — local source line analysis workbench &nbsp;·&nbsp;
8639    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
8640    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
8641  </footer>
8642
8643  <script>
8644    (function () {
8645      var storageKey = 'oxide-sloc-theme';
8646      var body = document.body;
8647      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
8648      var toggle = document.getElementById('theme-toggle');
8649      if (toggle) toggle.addEventListener('click', function () {
8650        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
8651        body.classList.toggle('dark-theme', next === 'dark');
8652        try { localStorage.setItem(storageKey, next); } catch(e) {}
8653      });
8654
8655      (function randomizeWatermarks() {
8656        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8657        if (!wms.length) return;
8658        var placed = [];
8659        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;}
8660        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];}
8661        var half=Math.floor(wms.length/2);
8662        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+';';});
8663      })();
8664
8665      (function spawnCodeParticles() {
8666        var container = document.getElementById('code-particles');
8667        if (!container) return;
8668        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'];
8669        for (var i = 0; i < 38; i++) {
8670          (function(idx) {
8671            var el = document.createElement('span');
8672            el.className = 'code-particle';
8673            el.textContent = snippets[idx % snippets.length];
8674            var left = Math.random() * 94 + 2;
8675            var top = Math.random() * 88 + 6;
8676            var dur = (Math.random() * 10 + 9).toFixed(1);
8677            var delay = (Math.random() * 18).toFixed(1);
8678            var rot = (Math.random() * 26 - 13).toFixed(1);
8679            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
8680            el.style.cssText = 'left:' + left.toFixed(1) + '%;top:' + top.toFixed(1) + '%;--rot:' + rot + 'deg;--op:' + op + ';animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
8681            container.appendChild(el);
8682          })(i);
8683        }
8684      })();
8685    })();
8686
8687    var activeStatusFilter = 'all';
8688    var deltaPerPage = 25, deltaCurrPage = 1;
8689
8690    function openFolder(path) {
8691      fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
8692    }
8693
8694    function getDeltaFilteredRows() {
8695      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
8696        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
8697      });
8698    }
8699
8700    function renderDeltaPage() {
8701      var filtered = getDeltaFilteredRows();
8702      var total = filtered.length;
8703      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
8704      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
8705      var start = (deltaCurrPage - 1) * deltaPerPage;
8706      var end = Math.min(start + deltaPerPage, total);
8707      var shownSet = {};
8708      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
8709      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
8710        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
8711      });
8712      var rl = document.getElementById('pg-range-label');
8713      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
8714      var info = document.getElementById('pg-info');
8715      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
8716      var btns = document.getElementById('pg-btns');
8717      if (!btns) return;
8718      btns.innerHTML = '';
8719      if (totalPages <= 1) return;
8720      function makeBtn(lbl, pg, active, disabled) {
8721        var b = document.createElement('button');
8722        b.className = 'pg-btn' + (active ? ' active' : '');
8723        b.textContent = lbl; b.disabled = disabled;
8724        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
8725        return b;
8726      }
8727      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
8728      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
8729      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
8730      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
8731    }
8732
8733    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
8734
8735    function filterRows(status, btn) {
8736      activeStatusFilter = status;
8737      deltaCurrPage = 1;
8738      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
8739        b.classList.remove('active');
8740      });
8741      if (btn) btn.classList.add('active');
8742      renderDeltaPage();
8743    }
8744
8745    // ── Sorting ──────────────────────────────────────────────────────────────
8746    var sortCol = null, sortOrder = 'asc';
8747    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
8748    (function() {
8749      var tbody = document.getElementById('delta-tbody');
8750      if (!tbody) return;
8751      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8752      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
8753    })();
8754
8755    function parseDeltaNum(str) {
8756      if (!str || str === '—') return 0;
8757      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
8758    }
8759
8760    sortHeaders.forEach(function(th) {
8761      th.addEventListener('click', function(e) {
8762        if (e.target.classList.contains('col-resize-handle')) return;
8763        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
8764        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
8765        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8766        th.classList.add('sort-' + sortOrder);
8767        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
8768        var tbody = document.getElementById('delta-tbody');
8769        if (!tbody) return;
8770        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8771        rows.sort(function(a, b) {
8772          var va, vb;
8773          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
8774          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
8775          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
8776          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
8777          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8778          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8779          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
8780          else { va = ''; vb = ''; }
8781          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
8782          return va < vb ? 1 : va > vb ? -1 : 0;
8783        });
8784        rows.forEach(function(r) { tbody.appendChild(r); });
8785        deltaCurrPage = 1;
8786        renderDeltaPage();
8787        var activeBtn = document.querySelector('.tab-btn.active');
8788        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
8789        if (activeBtn) activeBtn.classList.add('active');
8790      });
8791    });
8792
8793    // ── Column resize ─────────────────────────────────────────────────────────
8794    (function() {
8795      var table = document.getElementById('delta-table');
8796      if (!table) return;
8797      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
8798      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
8799      ths.forEach(function(th, i) {
8800        var handle = th.querySelector('.col-resize-handle');
8801        if (!handle || !cols[i]) return;
8802        var startX, startW;
8803        handle.addEventListener('mousedown', function(e) {
8804          e.stopPropagation(); e.preventDefault();
8805          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
8806          handle.classList.add('dragging');
8807          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
8808          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
8809          document.addEventListener('mousemove', onMove);
8810          document.addEventListener('mouseup', onUp);
8811        });
8812      });
8813    })();
8814
8815    // ── Reset ─────────────────────────────────────────────────────────────────
8816    window.resetDeltaTable = function() {
8817      sortCol = null; sortOrder = 'asc';
8818      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
8819      var tbody = document.getElementById('delta-tbody');
8820      if (tbody) {
8821        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
8822        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
8823        rows.forEach(function(r) { tbody.appendChild(r); });
8824      }
8825      var table = document.getElementById('delta-table');
8826      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
8827      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
8828      activeStatusFilter = 'all';
8829      deltaCurrPage = 1;
8830      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
8831      var allBtn = document.querySelector('.tab-btn');
8832      if (allBtn) allBtn.classList.add('active');
8833      renderDeltaPage();
8834    };
8835
8836    renderDeltaPage();
8837
8838    // ── Export helpers ────────────────────────────────────────────────────────
8839    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
8840    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
8841    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);}
8842    function slocMakeXlsx(fname,sd,dr){
8843      var enc=new TextEncoder();
8844      // CRC-32 table
8845      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;}
8846      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;}
8847      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
8848      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
8849      // Shared string table
8850      var ss=[],si={};
8851      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
8852      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
8853      // Worksheet builder — each WS() call gets its own row counter R
8854      function WS(){
8855        var R=0,buf=[];
8856        function cl(c){return String.fromCharCode(65+c);}
8857        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
8858          '<v>'+S(v)+'</v></c>';}
8859        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
8860          (st?' s="'+st+'"':'')+'>'+
8861          '<v>'+(+v)+'</v></c>';}
8862        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
8863        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
8864          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
8865          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
8866          '<sheetFormatPr defaultRowHeight="15"/>'+
8867          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
8868        return{sc:sc,nc:nc,row:row,xml:xml};
8869      }
8870      // Language breakdown
8871      var lm={};
8872      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;});
8873      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
8874      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
8875      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
8876      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
8877      // Summary sheet
8878      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
8879      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
8880      r1(s1(0,proj,2));
8881      r1(s1(0,sd.bts+' → '+sd.cts,2));
8882      r1('');
8883      r1(s1(0,'Metric',3)+s1(1,'Baseline',3)+s1(2,'Current',3)+s1(3,'Delta',3));
8884      r1(s1(0,'Code Lines')+n1(1,sd.bc,4)+n1(2,sd.cc,4)+s1(3,sd.cd,dstyle(sd.cd)));
8885      r1(s1(0,'Files Analyzed')+n1(1,sd.bf,4)+n1(2,sd.cf,4)+s1(3,sd.fd,dstyle(sd.fd)));
8886      r1(s1(0,'Comment Lines')+n1(1,sd.bcm,4)+n1(2,sd.ccm,4)+s1(3,sd.cmd,dstyle(sd.cmd)));
8887      r1('');
8888      r1(s1(0,'FILE CHANGES',8));
8889      r1(s1(0,'Category',3)+s1(3,'Count',3));
8890      r1(s1(0,'Modified')+n1(3,sd.fm,4));
8891      r1(s1(0,'Added')+n1(3,sd.fa,4));
8892      r1(s1(0,'Removed')+n1(3,sd.fr,4));
8893      r1(s1(0,'Unchanged')+n1(3,sd.fu,4));
8894      if(langs.length){
8895        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
8896        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
8897        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)));});
8898      }
8899      r1('');r1(s1(0,'SCAN METADATA',8));
8900      r1(s1(1,'Baseline')+s1(2,'Current'));
8901      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
8902      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
8903      var sh1=W1.xml('<col min="1" max="1" width="24" customWidth="1"/><col min="2" max="4" width="14" customWidth="1"/>');
8904      // File Delta sheet
8905      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
8906      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));
8907      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])));});
8908      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="8" width="13" customWidth="1"/>');
8909      // Shared strings XML
8910      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
8911        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
8912        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
8913      // XLSX file map
8914      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
8915      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>',
8916        '_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>',
8917        '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>',
8918        '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>',
8919        '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>',
8920        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
8921      // ZIP packer — STORED (no compression), compatible with all XLSX readers
8922      var zparts=[],zcds=[],zoff=0,znf=0;
8923      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
8924       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
8925      ].forEach(function(name){
8926        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
8927        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]);
8928        var entry=new Uint8Array(lha.length+nb.length+sz);
8929        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
8930        zparts.push(entry);
8931        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));
8932        var cde=new Uint8Array(cda.length+nb.length);
8933        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
8934        zcds.push(cde);zoff+=entry.length;znf++;
8935      });
8936      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
8937      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]);
8938      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
8939      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
8940      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
8941      zout.set(new Uint8Array(ea),zpos);
8942      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
8943      var xurl=URL.createObjectURL(xblob);
8944      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
8945      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
8946      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
8947    }
8948    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;');}
8949    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;}
8950
8951    var _summaryHdrs = ['Metric','Baseline','Current','Delta'];
8952    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 }}'};
8953    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)]];}
8954    var _dh = ['File','Language','Status','Code Before','Code After','Code Delta','Comment Delta','Total Delta'];
8955    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;}
8956    window.exportDeltaCsv = function(){slocCsvMulti(getExportFilename('csv'),[{hdrs:_summaryHdrs,rows:getSummaryExportRows()},{hdrs:_dh,rows:getDeltaExportRows()}]);};
8957    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
8958
8959    // ── Chart HTML report ─────────────────────────────────────────────────────
8960    function slocChartReport(fname, sd, dr) {
8961      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
8962      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
8963      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
8964      function fmt(n){return Number(n).toLocaleString();}
8965      function px(n){return Math.round(n);}
8966      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
8967      // Language map
8968      var lm={};
8969      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;});
8970      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
8971
8972      // Builds onmouse* attrs for interactive tooltip on each SVG element
8973      function barTT(label,val){
8974        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
8975      }
8976
8977      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
8978      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}];
8979      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
8980      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
8981      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
8982      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
8983      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"/>';}
8984      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
8985      c1mets.forEach(function(m,i){
8986        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
8987        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
8988        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>';
8989        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))+'/>';
8990        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>';
8991        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))+'/>';
8992        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>';
8993        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>';
8994        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>';
8995      });
8996      c1+='</svg>';
8997
8998      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
8999      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}];
9000      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
9001      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
9002      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
9003      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9004      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9005      mets.forEach(function(m,i){
9006        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
9007        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
9008        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
9009        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>';
9010        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
9011        if(bw>=52){
9012          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>';
9013        }else{
9014          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
9015          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>';
9016        }
9017      });
9018      c2+='</svg>';
9019
9020      // ── Chart 3: Language Code Delta ─────────────────────────────────────
9021      var c3='';
9022      if(langs.length){
9023        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
9024        var C3W=550,c3LW=124,c3FW=52;
9025        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
9026        var L3rH=30,C3H=langs.length*L3rH+20;
9027        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9028        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9029        langs.forEach(function(l,i){
9030          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
9031          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
9032          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
9033          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9034          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':''))+'/>';
9035          if(bw>=48){
9036            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>';
9037          }else{
9038            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
9039            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>';
9040          }
9041          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>';
9042        });
9043        c3+='</svg>';
9044      }
9045
9046      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
9047      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;});
9048      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
9049      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
9050      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9051      var ang=-Math.PI/2;
9052      segs.forEach(function(s){
9053        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
9054        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
9055        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
9056        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
9057        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
9058        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)+'%')+'/>';
9059        ang+=sw;
9060      });
9061      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>';
9062      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9063      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>';});
9064      c4+='</svg>';
9065
9066      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
9067      var ttJs='var tt=document.getElementById("ox-tt");'+
9068        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
9069        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
9070        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
9071        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
9072        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
9073        'function oxHT(){tt.style.display="none";}';
9074
9075      // body max-width keeps charts from inflating beyond design dimensions on
9076      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
9077      // each chart's height blows up proportionally, breaking the one-page layout.
9078      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;}'+
9079        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
9080        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
9081        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
9082        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
9083        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
9084        'svg{display:block;}'+
9085        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
9086        '#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;}'+
9087        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
9088      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
9089        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
9090        '<div id="ox-tt"><\/div>'+
9091        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
9092        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
9093        '<div class="two-col">'+
9094        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
9095        '<div class="leg"><span><span class="dot" style="background:#AAAAAA"><\/span>Baseline<\/span>'+
9096        '<span><span class="dot" style="background:#C45C10"><\/span>Current<\/span><\/div>'+c1+'<\/div>'+
9097        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
9098        '<\/div>'+
9099        '<div class="two-col">'+
9100        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
9101        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
9102        '<\/div>'+
9103        '<script>'+ttJs+'<\/script>'+
9104        '<\/body><\/html>';
9105      slocDownload(html, fname, 'text/html;charset=utf-8;');
9106    }
9107    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
9108  </script>
9109</body>
9110</html>
9111"##,
9112    ext = "html"
9113)]
9114struct CompareTemplate {
9115    baseline_run_id: String,
9116    current_run_id: String,
9117    baseline_run_id_short: String,
9118    current_run_id_short: String,
9119    baseline_timestamp: String,
9120    current_timestamp: String,
9121    project_path: String,
9122    baseline_code: u64,
9123    current_code: u64,
9124    code_lines_delta_str: String,
9125    code_lines_delta_class: String,
9126    baseline_files: u64,
9127    current_files: u64,
9128    files_analyzed_delta_str: String,
9129    files_analyzed_delta_class: String,
9130    baseline_comments: u64,
9131    current_comments: u64,
9132    comment_lines_delta_str: String,
9133    comment_lines_delta_class: String,
9134    files_added: usize,
9135    files_removed: usize,
9136    files_modified: usize,
9137    files_unchanged: usize,
9138    file_rows: Vec<CompareFileDeltaRow>,
9139    baseline_git_author: Option<String>,
9140    current_git_author: Option<String>,
9141    baseline_git_branch: String,
9142    current_git_branch: String,
9143    baseline_git_tags: Option<String>,
9144    current_git_tags: Option<String>,
9145}